diff --git a/AGENTS.md b/AGENTS.md index 5b47a2d71..85a6e2d07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`): |---------|-------------------|--------| | Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. | | Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. | -| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. | +| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. | | CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch | | RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services | | Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey | @@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`): **Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`. -**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured. +**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured. ## Plugin host runtime (internal preview) diff --git a/components/Terminal.tsx b/components/Terminal.tsx index 00df53663..381a73253 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -2841,8 +2841,8 @@ const TerminalComponent: React.FC = ({ currentRow: buffer.baseY + buffer.cursorY, lines, }; - }); - }, [sessionId]); + }, readTerminalContext); + }, [readTerminalContext, sessionId]); useEffect(() => { const startHandler = (event: Event) => { diff --git a/electron/bridges/mcpServerBridge.cjs b/electron/bridges/mcpServerBridge.cjs index 53a5da409..60dbf4cfe 100644 --- a/electron/bridges/mcpServerBridge.cjs +++ b/electron/bridges/mcpServerBridge.cjs @@ -1817,6 +1817,27 @@ async function cancelWorkerBackgroundJobsForTerminalSession(sessionId) { for (const jobId of matchingJobs) workerBackgroundJobs.delete(jobId); } +// Read via the existing renderer bridge and the sidebar's bounded reader. +async function handleReadContext(params = {}) { + const scopedIds = resolveScopedSessionIds(params.chatSessionId, params.scopedSessionIds); + const sessionId = typeof params.sessionId === "string" && params.sessionId.trim() + ? params.sessionId.trim() + : scopedIds?.length === 1 ? scopedIds[0] : null; + if (!sessionId) return { ok: false, error: "sessionId is required when the scope does not contain exactly one terminal." }; + const scopeError = validateSessionScope(sessionId, params.chatSessionId, params.scopedSessionIds); + if (scopeError) return { ok: false, error: scopeError }; + if (!invokeVaultAgentFn) return { ok: false, error: "Terminal context reader is unavailable." }; + const result = await invokeVaultAgentFn("terminal.readContext", { + sessionId, + range: params.range, + startLine: params.startLine, + maxLines: params.maxLines, + }); + // Scope can change while the renderer drains pending terminal output. + const currentScopeError = validateSessionScope(sessionId, params.chatSessionId, params.scopedSessionIds); + return currentScopeError ? { ok: false, error: currentScopeError } : result; +} + let builtinRpcHandlerRegistry = null; function getBuiltinRpcHandlerRegistry() { @@ -1826,6 +1847,7 @@ function getBuiltinRpcHandlerRegistry() { "meta.status": handleGetStatus, "attachment.list": handleListAttachments, "attachment.read": handleReadAttachment, + "harness.terminal.read_context": handleReadContext, "terminal.execute": handleExec, "sftp.list": handleSftpList, "sftp.read": handleSftpRead, diff --git a/electron/bridges/mcpServerBridge.readContext.test.cjs b/electron/bridges/mcpServerBridge.readContext.test.cjs new file mode 100644 index 000000000..4fba287dd --- /dev/null +++ b/electron/bridges/mcpServerBridge.readContext.test.cjs @@ -0,0 +1,60 @@ +"use strict"; +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs"); + +function setup(t, mode = "confirm") { + const path = require.resolve("./mcpServerBridge.cjs"); + delete require.cache[path]; + const bridge = require(path); + bridge.init({ sessions: new Map(), electronModule: null }); + bridge.setPermissionMode(mode); + bridge.updateSessionMetadata([{ sessionId: "a" }, { sessionId: "b" }], "chat"); + t.after(() => bridge.cleanup()); + return bridge; +} +for (const mode of ["observer", "confirm", "auto"]) { + test(`screen reads work without approval in ${mode} mode`, async (t) => { + const bridge = setup(t, mode); + const calls = []; + bridge.setVaultAgentInvoker(async (op, params) => { + calls.push({ op, params }); + return { ok: true, content: "existing output" }; + }); + const tool = listMcpTools().find((tool) => tool.mcpTool === "terminal_read_context"); + const result = await bridge.dispatchBuiltinRpc(tool.rpcMethod, { + chatSessionId: "chat", sessionId: "b", range: "tail", maxLines: 10, + }); + assert.equal(result.content, "existing output"); + assert.deepEqual(calls, [{ op: "terminal.readContext", params: { + sessionId: "b", range: "tail", startLine: undefined, maxLines: 10, + } }]); + }); +} +test("missing, ambiguous, empty and foreign scopes never reach the renderer", async (t) => { + const bridge = setup(t); + bridge.setVaultAgentInvoker(() => assert.fail("must not read")); + for (const params of [ + {}, { sessionId: "a" }, { chatSessionId: "chat" }, + { chatSessionId: "chat", sessionId: "foreign" }, + { chatSessionId: "chat", sessionId: "a", scopedSessionIds: [] }, + { chatSessionId: "chat", sessionId: "foreign", scopedSessionIds: ["foreign"] }, + ]) assert.equal((await bridge.dispatchBuiltinRpc("netcatty/readContext", params)).ok, false); +}); +test("one allowed terminal is inferred and revoked scope suppresses the result", async (t) => { + const bridge = setup(t); + bridge.setVaultAgentInvoker(async (_op, params) => ({ ok: true, sessionId: params.sessionId })); + const result = await bridge.dispatchBuiltinRpc("netcatty/readContext", { + chatSessionId: "chat", scopedSessionIds: ["b"], + }); + assert.equal(result.sessionId, "b"); + bridge.setVaultAgentInvoker(async () => { + bridge.updateSessionMetadata([], "chat"); + return { ok: true, content: "do not disclose" }; + }); + const revoked = await bridge.dispatchBuiltinRpc("netcatty/readContext", { + chatSessionId: "chat", sessionId: "a", + }); + assert.equal(revoked.ok, false); + assert.equal(revoked.content, undefined); +}); diff --git a/electron/capabilities/catalog/harness.cjs b/electron/capabilities/catalog/harness.cjs index 4dcd0b923..13a019fbf 100644 --- a/electron/capabilities/catalog/harness.cjs +++ b/electron/capabilities/catalog/harness.cjs @@ -2,7 +2,7 @@ const { CAPABILITY_STATUS } = require("../constants.cjs"); -/** Catty-only harness tools (sidebar agent; renderer-local; not MCP/CLI). */ +/** Renderer-local harness tools; terminal reading also exposes a scoped MCP entry. */ /** @type {import("../types.cjs").CapabilityDefinition[]} */ const HARNESS_CAPABILITIES = [ { @@ -61,6 +61,7 @@ const HARNESS_CAPABILITIES = [ }, { id: "harness.terminal.read_context", + agentKinds: ["sidebar"], domain: "harness", status: CAPABILITY_STATUS.IMPLEMENTED, description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.", @@ -75,6 +76,7 @@ const HARNESS_CAPABILITIES = [ }, surfaces: { catty: { toolName: "terminal_read_context" }, + builtin: { rpcMethod: "netcatty/readContext", mcpTool: "terminal_read_context" }, }, }, { diff --git a/electron/capabilities/codegen/toolSurfaces.test.cjs b/electron/capabilities/codegen/toolSurfaces.test.cjs index 8340c5080..11c377633 100644 --- a/electron/capabilities/codegen/toolSurfaces.test.cjs +++ b/electron/capabilities/codegen/toolSurfaces.test.cjs @@ -160,14 +160,15 @@ test("listAgentToolSpecs splits sidebar harness tools from shared RPC tools", () assert.ok(globalIds.every((id) => sidebarIds.includes(id) || id.startsWith("harness.") === false)); }); -test("listCattyToolSpecs includes harness catty-only tools with local execution", () => { +test("listCattyToolSpecs includes renderer harness tools and the shared terminal read entry", () => { const specs = listCattyToolSpecs(); assert.ok(specs.length >= 40); const harness = specs.filter((spec) => spec.capabilityId.startsWith("harness.")); assert.equal(harness.length, 6); for (const spec of harness) { - assert.equal(spec.localExecution, true); - assert.equal(spec.rpcMethod, null); + const screenRead = spec.capabilityId === "harness.terminal.read_context"; + assert.equal(spec.localExecution, !screenRead); + assert.equal(spec.rpcMethod, screenRead ? "netcatty/readContext" : null); } const harnessIds = harness.map((spec) => spec.capabilityId); assert.ok(harnessIds.includes("harness.tool_output.read")); @@ -175,10 +176,11 @@ test("listCattyToolSpecs includes harness catty-only tools with local execution" assert.ok(harnessIds.includes("harness.terminal.read_context")); }); -test("harness capabilities are not exposed on MCP", () => { +test("only terminal reading is exposed from harness on MCP", () => { const mcpCapabilityIds = listMcpTools().map((tool) => tool.capabilityId); + assert.ok(mcpCapabilityIds.includes("harness.terminal.read_context")); for (const capabilityId of mcpCapabilityIds) { - assert.ok(!capabilityId.startsWith("harness.")); + assert.ok(!capabilityId.startsWith("harness.") || capabilityId === "harness.terminal.read_context"); } }); diff --git a/infrastructure/ai/harness/generated/cattyToolSpecs.json b/infrastructure/ai/harness/generated/cattyToolSpecs.json index fdba2764b..3f6240773 100644 --- a/infrastructure/ai/harness/generated/cattyToolSpecs.json +++ b/infrastructure/ai/harness/generated/cattyToolSpecs.json @@ -2256,8 +2256,8 @@ { "capabilityId": "harness.terminal.read_context", "toolName": "terminal_read_context", - "rpcMethod": null, - "localExecution": true, + "rpcMethod": "netcatty/readContext", + "localExecution": false, "description": "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.", "inputShape": { "sessionId": { diff --git a/infrastructure/ai/vaultAgentBridgeClient.test.ts b/infrastructure/ai/vaultAgentBridgeClient.test.ts index 5a51fa020..513019b83 100644 --- a/infrastructure/ai/vaultAgentBridgeClient.test.ts +++ b/infrastructure/ai/vaultAgentBridgeClient.test.ts @@ -2142,3 +2142,31 @@ describe('runSerializedVaultAgentRequest', () => { assert.equal(value, 2); }); }); + +describe('terminal.readContext', () => { + it('uses the registered bounded reader, preserving script snapshots and cleanup', async () => { + const { registerScreenSnapshotProvider, captureScreenSnapshot } = await import('../scripts/screenSnapshotRegistry'); + const { buildTerminalContextReadResult } = await import('../../domain/terminalContextRead'); + const snapshot = { rows: 2, cols: 80, currentRow: 1, lines: ['visible', 'prompt'] }; + const lines = Array.from({ length: 500 }, (_, i) => `line ${i}`); + const dispose = registerScreenSnapshotProvider('read-test', () => snapshot, async (request) => + buildTerminalContextReadResult({ ...request, fullText: lines.join('\n'), source: 'snapshot' })); + try { + assert.deepEqual(captureScreenSnapshot('read-test'), snapshot); + const result = await handleVaultAgentOp('terminal.readContext', { + sessionId: 'read-test', range: 'tail', maxLines: 100000, + }, createDeps()); + assert.equal(result.ok, true); + assert.equal(result.returnedLines, 300); + assert.equal(result.startLine, 200); + assert.equal(result.source, 'snapshot'); + const selected = await handleVaultAgentOp('terminal.readContext', { + sessionId: 'read-test', range: 'lines', startLine: 10, maxLines: 2, + }, createDeps()); + assert.equal(selected.content, 'line 10\nline 11'); + } finally { + dispose(); + } + assert.equal((await handleVaultAgentOp('terminal.readContext', { sessionId: 'read-test' }, createDeps())).ok, false); + }); +}); diff --git a/infrastructure/ai/vaultAgentBridgeClient.ts b/infrastructure/ai/vaultAgentBridgeClient.ts index b53d8bcb5..df71381e1 100644 --- a/infrastructure/ai/vaultAgentBridgeClient.ts +++ b/infrastructure/ai/vaultAgentBridgeClient.ts @@ -1,3 +1,5 @@ +import { readScreenContext } from "../scripts/screenSnapshotRegistry"; +import { normalizeTerminalContextRange } from "../../domain/terminalContextRead"; import { resolveHostOs } from '../../domain/host'; import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models'; import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases'; @@ -570,6 +572,16 @@ export async function handleVaultAgentOp( deps: VaultAgentApiDeps, ): Promise> { switch (op) { + case 'terminal.readContext': { + const sessionId = typeof params.sessionId === 'string' ? params.sessionId : ''; + if (!sessionId) return { ok: false, error: 'sessionId is required.' }; + return { ...await readScreenContext({ + sessionId, + range: normalizeTerminalContextRange(params.range), + startLine: typeof params.startLine === 'number' ? params.startLine : undefined, + maxLines: typeof params.maxLines === 'number' ? params.maxLines : undefined, + }) }; + } case 'session.close': { const sessionId = String(params.sessionId || '').trim(); if (!sessionId) return { ok: false, error: 'sessionId is required.' }; diff --git a/infrastructure/scripts/screenSnapshotRegistry.ts b/infrastructure/scripts/screenSnapshotRegistry.ts index a737dfc1e..8a161e4a7 100644 --- a/infrastructure/scripts/screenSnapshotRegistry.ts +++ b/infrastructure/scripts/screenSnapshotRegistry.ts @@ -1,3 +1,5 @@ +import type { TerminalContextReader } from "../../domain/terminalContextRead"; + export type ScreenSnapshotProvider = () => { rows: number; cols: number; @@ -7,15 +9,17 @@ export type ScreenSnapshotProvider = () => { source?: string; }; -const providers = new Map(); +const providers = new Map(); export function registerScreenSnapshotProvider( sessionId: string, provider: ScreenSnapshotProvider, + readContext?: TerminalContextReader, ): () => void { - providers.set(sessionId, provider); + const entry = { snapshot: provider, readContext }; + providers.set(sessionId, entry); return () => { - if (providers.get(sessionId) === provider) { + if (providers.get(sessionId) === entry) { providers.delete(sessionId); } }; @@ -31,5 +35,12 @@ export function captureScreenSnapshot(sessionId: string) { lines: [] as string[], }; } - return provider(); + return provider.snapshot(); } + +/** Uses the same bounded reader as the sidebar, including hibernated terminals. */ +export const readScreenContext: TerminalContextReader = async (request) => { + const reader = providers.get(request.sessionId)?.readContext; + if (!reader) return { ok: false, error: "Terminal context reader is unavailable for this session." }; + return reader(request); +};