diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 10247d6..d8e19b8 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -22,6 +22,11 @@ The installed tool policy is the intersection of compiled tools, remote `allowedTools`, remote write enablement, local write enablement, and helper capabilities. A connection-generation change revokes queued and future work. +`advertisedTools` is derived from AgentV's live tool definitions. Each entry +identifies the exact registered tool name and its read/write capability; it is +recomputed for every connection and is not a fallback grant. Write tools are +advertised only when the local helper is ready. + ## Snapshot `notify/snapshot` is gzip-compressed and contains `host_summary`, bounded diff --git a/docs/contracts/manifest.json b/docs/contracts/manifest.json index 7b5018b..d741a76 100644 --- a/docs/contracts/manifest.json +++ b/docs/contracts/manifest.json @@ -5,12 +5,12 @@ "counterparts": { "control-plane": { "contractVersion": 2, - "websocketPaths": ["/api/v1/agent/connect"], "agentHeaders": ["x-agent-key", "x-agent-version"], "handshakeRequestFields": [ "agentKey", "agentVersion", "targetId", "targetType", "agentType", "supportedCapabilities", "hostFeatures.osFamily", "hostFeatures.serviceManager", - "hostFeatures.helperReachable", "hostFeatures.restartServices" + "hostFeatures.helperReachable", "hostFeatures.restartServices", + "advertisedTools[].name", "advertisedTools[].capability" ], "handshakeResponseFields": [ "workspaceId", "targetId", "targetType", "sessionPolicy.allowedTools", diff --git a/scripts/check-contracts.mjs b/scripts/check-contracts.mjs index 27a3eae..2ba9e40 100644 --- a/scripts/check-contracts.mjs +++ b/scripts/check-contracts.mjs @@ -14,6 +14,7 @@ function read(relativePath) { const doc = read('docs/contracts/README.md'); const manifest = JSON.parse(read('docs/contracts/manifest.json')); +const websocketClient = read('src/transport/websocket-client.ts'); expect(manifest.repo === 'agentv', 'Manifest repo must be agentv'); expect(manifest.version === 1, 'Manifest harness schema version must be 1'); @@ -26,7 +27,7 @@ expect(doc.includes('agentType = "agentv"'), 'Contract doc missing AgentV type') const controlPlane = manifest.counterparts?.['control-plane']; expect(Boolean(controlPlane), 'Manifest must include control-plane counterpart'); -expect(controlPlane?.websocketPaths?.includes('/api/v1/agent/connect'), 'Manifest missing primary agent WebSocket path'); +expect(websocketClient.includes('/api/v1/agent/connect'), 'WebSocket client missing primary control-plane path'); expect(controlPlane?.rpcMethods?.includes('tools/list'), 'Manifest missing tools/list RPC method'); expect(controlPlane?.rpcMethods?.includes('tools/call'), 'Manifest missing tools/call RPC method'); expect(controlPlane?.builtinToolNames?.includes('get_host_summary'), 'Manifest missing built-in VM tool names'); diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index ce67efa..d2b58c6 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -63,9 +63,12 @@ export class LifecycleManager { const supportedCapabilities = ['read', 'mcp', 'systemd', 'linux']; if (this.config.allowedLogUnits.length > 0) supportedCapabilities.push('logs'); if (this.helperReady) supportedCapabilities.push('write', 'restart_service'); + const advertisedTools = toolRegistry.getAll() + .filter((tool) => tool.capability === 'read' || this.helperReady) + .map((tool) => ({ name: tool.name, capability: tool.capability })); const handshake = createRequest('lifecycle/handshake', { targetId: this.config.targetId, targetType: 'virtual_machine', agentType: 'agentv', agentKey: this.config.agentKey, - version: this.config.agentVersion, agentVersion: this.config.agentVersion, supportedCapabilities, + version: this.config.agentVersion, agentVersion: this.config.agentVersion, supportedCapabilities, advertisedTools, hostFeatures: { osFamily: 'linux', serviceManager: 'systemd', helperReachable: this.helperReady, restartServices: helperServices }, }, HANDSHAKE_ID); if (!this.transport.send(JSON.stringify(handshake))) { this.transport.forceReconnect(); return; } diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts index a5b4733..8d5902b 100644 --- a/src/e2e.spec.ts +++ b/src/e2e.spec.ts @@ -102,6 +102,19 @@ describe('AgentV WebSocket lifecycle', () => { await new Promise((resolve) => server.once('listening', resolve)); const port = (server.address() as { port: number }).port; let connections = 0; + let capabilityChecks = 0; + const advertisedToolNames: string[][] = []; + const reconnectActions: ActionClient = { + async capabilities() { + capabilityChecks++; + return { + protocol_version: 1, + policy_valid: capabilityChecks > 1, + restart_services: capabilityChecks > 1 ? ['acornops-agentv.service'] : [], + }; + }, + async restart() { throw new Error('not exercised'); }, + }; const reauthenticated = new Promise((resolve) => server.on('connection', (socket) => { connections++; const connection = connections; @@ -109,6 +122,7 @@ describe('AgentV WebSocket lifecycle', () => { if (binary) return; const message = JSON.parse(raw.toString()); if (message.method !== 'lifecycle/handshake') return; + advertisedToolNames.push(message.params.advertisedTools.map((tool: { name: string }) => tool.name)); socket.send(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { @@ -120,9 +134,9 @@ describe('AgentV WebSocket lifecycle', () => { else resolve(); }); })); - const host = new MockHostAdapter(); registerAllTools(host, actions); + const host = new MockHostAdapter(); registerAllTools(host, reconnectActions); const lifecycle = new LifecycleManager( - config(port), host, actions, + { ...config(port), writeEnabled: true }, host, reconnectActions, new McpRouter(new ToolExecutor(), createLogger('error')), createLogger('error'), new Observability(), ); @@ -130,5 +144,7 @@ describe('AgentV WebSocket lifecycle', () => { await reauthenticated; lifecycle.stop(); expect(connections).toBe(2); + expect(advertisedToolNames[0]).not.toContain('restart_service'); + expect(advertisedToolNames[1]).toContain('restart_service'); }, 4_000); });