mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
feat: expose scoped terminal screen reads to MCP
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -2841,8 +2841,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
currentRow: buffer.baseY + buffer.cursorY,
|
||||
lines,
|
||||
};
|
||||
});
|
||||
}, [sessionId]);
|
||||
}, readTerminalContext);
|
||||
}, [readTerminalContext, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const startHandler = (event: Event) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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.' };
|
||||
|
||||
@@ -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<string, ScreenSnapshotProvider>();
|
||||
const providers = new Map<string, { snapshot: ScreenSnapshotProvider; readContext?: TerminalContextReader }>();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user