Compare commits

...
34 changed files with 2418 additions and 40 deletions

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
# Global Agent plugin bridge (work in progress)
This stack starts from `integration/plugin-ask-routing-acceptance` at
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
its native Ask acceptance and integrated CI are still pending. This branch does
not modify the frozen candidate, #3986, #3998, or #4002.
The user approved a global conversation independent of a terminal or workspace,
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
Agent execution, history, target authority, native approvals, credentials and
audit. Global is not unrestricted inventory access or a background service.
## Stage 1: dispatch authority
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
register plugin APIs, start an Agent or implement an orchestration loop.
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
synchronous current-owner checks, and a supported capability subset. The public
result contains only an opaque handle and expiry. Plugin parameters must never
be used as `hostSnapshot`.
- `begin(handle, context)` returns an internal turn ticket and AbortController.
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
adapter once, checks current target revisions after any approval wait, invokes
the real dispatcher, and rejects late results. `resolvedAction` must come from
canonical host capability/argument resolution, not an unchecked plugin object.
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
- Handles expire in five minutes. There are at most eight per runtime owner and
64 overall, with at most 32 explicit resources per scope. Expiry never silently
refreshes a selection or revives an old turn.
The focused tests use the actual `PluginAISourceBinding` with synthetic host
registries, two terminal owners representing different workspaces, controlled
time, and deferred approval/native completion. They cover source/runtime/target
substitution, focus changes, revision changes across approval, policy denial,
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
or AgentRuntime dispatch; those remain integration gates.
Initial execution failed before test discovery because the new worktree has no
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
paid credentials or GUI was used.
The adjacent run then passed 49 tests and failed one installed-entrypoint test
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
failures are setup evidence, not failed-before product regressions.
The core conversation repository stores the existing global `AISession` document
shape in a host-only directory, namespaced by the hash of source/private-owner.
Native session identity and compaction remain private. Commits serialize a cloned
snapshot, revalidate provenance before atomic rename, and retain previous history
when that check fails. Files use mode 0600 and no-follow reads. The repository
does not register a plugin storage API or change the source database schema.
The canonical schema and SDK now declare the proposed session interface and
permissions. Runtime methods and the supported-feature advertisement are not yet
registered. This stage must not be described as an installable working Agent
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
## Independent stage-1 correction
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
confirmed two helper defects before any runtime acceptance claim: source failure
during the initial owner check rejected late work without aborting the active
native signal; Stop could acquire a replacement turn after awaiting identity.
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
the focused before run is 13 passed / 2 failed. The correction revokes the captured
scope when its own host-authenticated identity fails, and captures the intended
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
`/tmp/netcatty-global-agent-review-after.log`,
`/tmp/netcatty-global-agent-review-node22.log`.
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
after an explicit runner shutdown and operation cancellation, with no assertion
failure in its log and no complete repository test total. Package workflow
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
it was requested once, producing Linux job `122048349748`, still running at this
checkpoint. Original runner failure remains historical evidence. Neither that
rerun nor the helper fix establishes runtime, native or independent-review
acceptance; the correction still needs reviewer recheck.
### Follow-up: cancellation is not source revocation
The independent recheck confirmed the first two repairs and found a remaining
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
after B had started, revoking B's scope. The same failure was reproduced at both
owner-validation await paths using the actual source binding. Exact-18377 source
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
The minimal follow-up distinguishes this request's abort/deadline from verified
owner or resource failure, rethrows the access failure, and leaves the existing
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
regression confirms that a verified source mismatch still aborts native work
even when the RPC is concurrently cancelled. Foreign caller protections remain.
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
24.19.0; focused lint and diff checks pass. Logs:
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
operation-cancelled log and no assertion failure. No second rerun was requested.
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
repository total or an assertion failure. Both Windows jobs passed. No blind
rerun of that job was made. Package workflow `40462595846` was still running when
checked. These records are not substituted for exact-head green CI.
## Planned public contract and adapters
### Independent transport correction
The exact `ea619555` internal-handler reproductions fail all four targeted cases:
both no-session and empty-selection environment reads serialize global tool hints;
stale chat-wide cleanup cancels replacement B; and caller-supplied builtin chat
text can downgrade cancellation of a main-owned delegated request. The outer
sender gate still accepts only the current trusted main window. These are trusted
application lifecycle defects, not arbitrary plugin IPC exposure or installed
runtime exploit evidence.
Cancellation now resolves ownership from the main request map, rejects a
conflicting caller chat, checks the recorded sender and current turn lease, and
cancels only that native request. The legacy chat-wide cleanup IPC is unavailable
for reserved delegated conversations; main-owned exact-turn release remains the
cleanup authority. All delegated environment return paths omit global tool hints.
An additional Node 22 regression is independently reproduced: inline arrow
properties at the beginning of `module.exports` prevent namespace detection of
existing exports such as `init`, breaking the real Catty attachment harness.
Identifier exports preserve that existing interface. Correction tests pass 94 on
Node 22.23.3, including the actual Catty attachment consumer; focused lint passes.
Failed-before logs are `/tmp/netcatty-global-agent-transport-review-four-failed-before.log`
and `/tmp/netcatty-global-agent-ea619-namespace-before.log`. The transport PR's
review checkpoint remains `ea619555` until the parent integrates/rechecks this
separate correction. No native/backend/full-CI acceptance is implied.
### Stage 2A: trusted native dispatch boundary
The independent bounded recheck of foundation `ba4d3fc` passed all 20 reviewer
cases. This resolves its reported helper findings; it does not establish full CI,
runtime, backend or native acceptance.
The additive transport slice gives managed subprocesses a separate per-turn MCP
credential, permanently binds their TCP connection to that turn, filters their
tool list, and routes actual tool execution through the source-bound host policy
adapter. Catty's trusted-app IPC enters the same dispatcher. Branded approval
contexts skip the legacy Agent approval only after that host adapter grants the
action; process-global permission mode is unchanged. Selected environment reads
return the scoped session metadata without querying global tunnel inventory.
Managed Claude is restricted to its existing MCP mode with builtin tools disabled,
strict MCP configuration and no inherited settings sources. The main native entry
rejects unverified backends and Skills mode for reserved delegated conversations.
Each model stream and native SDK request carries a main-owned turn tag; releasing
that lease aborts its HTTP/native work. Old credentials, cancellation requests and
disposers cannot acquire or cancel the next turn.
The focused boundary suite passes 116 cases, including an actual local TCP/main
handler with synthetic workers, plus two new actual-HTTP/renderer-transport lease
tests. These are deterministic transport tests, not real model inference or
authenticated Claude service/CLI acceptance. Public runtime registration, core
conversation integration and the custom chat sample are still separate work in
progress; this transport slice alone is not a usable plugin Agent feature.
At exact `ba4d3fc`, package CI `40464058910` passed; test workflow `40464058908`
is red. Linux job `122055141575` failed a real assertion in
`PluginManagementDialog.interaction.test.tsx:71`: the external view handoff had
not run when asserted. That stage reports 34 passed / 1 failed; earlier runtime
and contract groups passed 720 and 5. Both Windows jobs passed. No blind rerun was
requested. This unresolved assertion must not be classified as runner shutdown.
The wider initial local transport run reports 697 passed / 10 failed / 3 skipped.
All ten failures are Cursor discovery expectations after `npm ci --ignore-scripts`
left SQLite's native binding absent, making the bundled Cursor SDK import fail.
The standard rebuild first failed using unwritable default caches; rebuilding
with task-local npm/node-gyp cache paths succeeded. The failed totals are retained
in `/tmp/netcatty-global-agent-transport-expanded.log`; the after-setup run is
recorded separately. No GUI or provider credentials were used.
After the dependency repair, the expanded transport/native-handler/preload/SDK
suite passes 709 tests, 0 failures, 3 skips. Log:
`/tmp/netcatty-global-agent-transport-expanded-after-setup.log`. Focused lint passes.
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
names, separate from the existing tools-disabled `ai.invoke` broker.
The API will provide backend listing, explicit target selection, owned chat
listing/opening, sending, bounded event reads, history, Stop and close. Backend
references are opaque host-issued configuration revisions. Conversation references
identify only histories owned by the calling source/private-owner; runtime handles
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
entries must return explicit unsupported/unconfigured status until their native
tool and approval boundaries are verified. Codex SDK's current unrestricted native
configuration must not be treated as eligible merely because it has a driver.
The terminal worker already exposes a suitable main-owned resource binding:
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
session generation. Use this for dispatch-time targets; do not use menu IDs,
focused tab, display labels, or a renderer-provided session ID as authority.
The MCP dispatcher and Catty direct execution route need the same delegated
session guard. A delegated call must not update process-global permission mode,
or request both an Agent approval and a plugin approval for the same action.
Policy changes, disable/restart/source replacement, lock and host disconnection
must cancel scoped native work and invalidate outgoing event tickets. Existing
Agent trace and plugin audit/log facilities will receive redacted correlation
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
## Remaining acceptance gates
- Canonical schema/SDK/runtime registration and the installed default host path.
- Host-owned durable history and backend references, including backend switching.
- Actual Catty and Claude MCP dispatch, policy and worker lifecycle regressions.
- Synthetic streaming/cancellation fixtures; these are not real inference or
actual authenticated Claude service acceptance.
- Installable minimal chat UI with backend and explicit target choice.
- Standard CI, independent review and parent-scheduled native evidence. No GUI
ownership, merge or release is granted to this executor.
+2
View File
@@ -3,6 +3,8 @@
// Presentation only. Admission and grants remain owned by the canonical schema
// and permission engine. Tests require exact coverage of its permission enum.
const PERMISSION_DESCRIPTIONS = {
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
+17 -2
View File
@@ -611,11 +611,21 @@ function withContentLength(headers, body) {
}
async function streamRequest(url, options, event, requestId, skipTLS) {
if (requestId.startsWith("agent-model_") && !/^agent-model_[0-9a-f-]{36}_[0-9a-f-]{36}_sdk_/.test(requestId)) {
throw new Error("Delegated model request identity invalid");
}
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
const delegatedMatch = /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_sdk_/.exec(requestId);
const delegatedId = delegatedMatch?.[1];
const delegationTag = delegatedMatch?.[2];
const delegatedBridge = delegatedId ? require("./mcpServerBridge.cjs") : null;
const assertDelegation = () => delegatedBridge?.assertPluginAgentCaller(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
assertDelegation();
const detachDelegation = delegatedBridge?.onPluginAgentRelease(`plugin-agent:${delegatedId}`, delegationTag, () => controller.abort(createAbortError()));
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId || delegatedId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
@@ -629,6 +639,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
detachDelegation?.();
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -648,6 +659,9 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
}
try { assertDelegation(); }
catch (error) { finishLifecycle(); throw error; }
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
let proxyAgent;
try {
@@ -731,6 +745,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
if (proxyAgent) reqOpts.agent = proxyAgent;
try {
assertDelegation();
req = lib.request(parsedUrl, reqOpts,
(res) => {
if (streamFinished) {
@@ -795,7 +810,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
if (pluginRuntimeId || delegatedId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
+32
View File
@@ -46,6 +46,7 @@ function loadBridgeWithMocks(options = {}) {
clearPendingApprovals() {},
cleanupScopedMetadata: async () => {},
cleanup() {},
...options.mcpServerBridge,
},
"../cli/discoveryPath.cjs": {
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
@@ -1725,6 +1726,37 @@ async function withCapturingServer(t, options, respond) {
};
}
test("delegated model stream release aborts its actual HTTP request and stale turns fail before HTTP", { timeout: 5_000 }, async (t) => {
const { randomUUID } = require("node:crypto");
const id = randomUUID(); const tag = randomUUID(); const chatId = `plugin-agent:${id}`;
let live = true; let release; let detached = 0; let requests = 0;
let resolveAccepted; const accepted = new Promise((resolve) => { resolveAccepted = resolve; });
const ctx = await withCapturingServer(t, { mcpServerBridge: {
assertPluginAgentCaller: (actual, sender, actualTag) => {
assert.equal(actual, chatId); assert.equal(sender, 1);
if (!live || actualTag !== tag) throw Error("Agent turn unavailable");
},
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
} }, (res) => {
requests++; res.writeHead(200, { "content-type": "text/event-stream" });
res.write('data: {"synthetic":true}\n\n'); resolveAccepted();
});
try {
const invoke = (turnTag) => ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")({ sender: ctx.sender }, {
requestId: `agent-model_${id}_${turnTag}_sdk_fixture`, url: `${ctx.baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" }, body: '{"stream":true}', providerId: "content-length",
});
const ended = new Promise((resolve) => ctx.onStreamEvent((channel) => {
if (channel === "netcatty:ai:stream:error" || channel === "netcatty:ai:stream:end") resolve();
}));
const pending = invoke(tag); await accepted; live = false; release();
await pending; await ended;
assert.equal(detached, 1); assert.equal(requests, 1);
const stale = await invoke(randomUUID());
assert.equal(stale.ok, false); assert.equal(requests, 1);
} finally { ctx.restore(); }
});
test("streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
const ctx = await withCapturingServer(t, {}, (res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
@@ -4,8 +4,12 @@ function registerAgentProcessHandlers(ctx) {
const maxCommandTimeoutSeconds = 24 * 60 * 60;
// ── MCP Server session metadata ──
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId, delegationTag }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
catch { return { ok: false, error: "Agent scope update unavailable" }; }
}
const list = Array.isArray(sessionList) ? sessionList : [];
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
if (chatSessionId === externalId) {
@@ -87,11 +87,20 @@ async function proxyCattyExecToWorker({
function registerCattyExecHandlers(ctx) {
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
with (ctx) {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId, delegationTag }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try {
return await mcpServerBridge.dispatchBuiltinRpc("netcatty/exec", { sessionId, command, chatSessionId }, {
trustedAgentSenderId: event.sender.id,
trustedAgentTurnTag: delegationTag,
beforeExecute: () => assertCattyPrivacySession(privacyService, chatSessionId),
});
} catch { return { ok: false, error: "Delegated Agent execution unavailable" }; }
}
// Block execution in observer mode (Issue #11)
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
@@ -293,10 +302,14 @@ function registerCattyExecHandlers(ctx) {
});
// Cancel in-flight Catty Agent command executions for a chat session
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
catch { return { ok: false, error: "Agent cancellation unavailable" }; }
}
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
@@ -313,7 +326,7 @@ function registerCattyExecHandlers(ctx) {
return { ok: true };
});
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
@@ -321,13 +334,14 @@ function registerCattyExecHandlers(ctx) {
return { ok: false, error: "chatSessionId is required" };
}
try {
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
@@ -340,7 +354,7 @@ function registerCattyExecHandlers(ctx) {
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
}, { beforeExecute });
}, { beforeExecute, trustedAgentSenderId: event.sender.id, trustedAgentTurnTag: delegationTag });
} catch (err) {
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
}
@@ -101,7 +101,9 @@ function mergeClaudeEffortSettings(settings, effort) {
function buildClaudeQueryOptions({
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
toolIntegrationMode,
delegatedAgent = false,
}) {
if (delegatedAgent && toolIntegrationMode !== "mcp") throw Error("Delegated Claude requires MCP mode");
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
const options = {
cwd,
@@ -130,8 +132,12 @@ function buildClaudeQueryOptions({
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
}
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
const parsedSettings = mergeClaudeEffortSettings(delegatedAgent ? undefined : parseClaudeSettings(settings), effort);
if (parsedSettings !== undefined) options.settings = parsedSettings;
if (delegatedAgent) {
options.strictMcpConfig = true;
options.settingSources = [];
}
return options;
}
@@ -333,3 +333,16 @@ test("listClaudeModels aborts a hung SDK query and returns it for cleanup", asyn
assert.equal(queryAbortSignal.aborted, true);
assert.equal(returnCount, 1);
});
test("delegated Claude uses only explicit MCP configuration and cannot inherit local settings/tool mode", () => {
const options = buildClaudeQueryOptions({
delegatedAgent: true, toolIntegrationMode: "mcp", settings: { mcpServers: { unwanted: {} } },
injectedMcpServers: [{ name: "scoped-netcatty", command: "host-managed", args: [], env: [] }],
});
assert.equal(options.strictMcpConfig, true);
assert.deepEqual(options.settingSources, []);
assert.deepEqual(options.tools, []);
assert.equal(options.settings, undefined);
assert.deepEqual(Object.keys(options.mcpServers), ["scoped-netcatty"]);
assert.throws(() => buildClaudeQueryOptions({ delegatedAgent: true, toolIntegrationMode: "skills" }), /requires MCP/);
});
+1
View File
@@ -44,6 +44,7 @@ const DRIVER_REGISTRY = {
settings: ctx.claudeSettings,
resume: ctx.resumeSessionId,
toolIntegrationMode: ctx.toolIntegrationMode,
delegatedAgent: ctx.delegatedAgent === true,
});
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
},
@@ -13,6 +13,7 @@ async function buildInjectedMcpServers({
mcpServerBridge,
chatSessionId,
toolIntegrationMode,
delegationTag,
}) {
try {
// Start the netcatty control host for BOTH modes. getOrCreateHost binds the
@@ -30,8 +31,22 @@ async function buildInjectedMcpServers({
scopedIds,
chatSessionId,
);
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
const delegated = mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
// Replace, never append alongside, the app-wide credential. These values
// stay in the managed subprocess configuration and never reach the plugin.
netcattyMcpConfig.env = netcattyMcpConfig.env.filter((entry) => ![
"NETCATTY_MCP_TOKEN", "NETCATTY_MCP_DEBUG", "NETCATTY_MCP_PERMISSION_MODE",
].includes(entry.name));
netcattyMcpConfig.env.push(
{ name: "NETCATTY_MCP_TOKEN", value: delegated.token },
{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" },
{ name: "NETCATTY_MCP_DELEGATED_TOOLS", value: JSON.stringify(delegated.tools) },
);
}
return [netcattyMcpConfig];
} catch (err) {
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) throw Error("Delegated Agent MCP is unavailable");
console.error("[sdk] Failed to ensure netcatty host / inject MCP server:", err?.message || err);
return [];
}
@@ -744,6 +744,22 @@ function registerSdkStreamHandlers(ctx) {
} = payload;
const backendKey = resolveBackendKey(sdkBackend);
const delegatedAgent = mcpServerBridge.isPluginAgentConversation?.(chatSessionId) === true;
const delegationTag = delegatedAgent ? /^plugin-agent_([0-9a-f-]{36})_/.exec(requestId)?.[1] : undefined;
if (delegatedAgent && (backendKey !== "claude" || toolIntegrationMode !== "mcp")) {
throw Error("This backend is not eligible for delegated Agent sessions");
}
if (delegatedAgent) {
mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
}
let delegatedCwd;
if (delegatedAgent) {
const nativePath = require("node:path");
const name = require("node:crypto").createHash("sha256").update(chatSessionId).digest("hex");
delegatedCwd = nativePath.join(tempDirBridge.getTempDir(), "plugin-agent", name);
require("node:fs").mkdirSync(delegatedCwd, { recursive: true, mode: 0o700 });
}
if (!backendKey) {
safeSend(event.sender, "netcatty:ai:sdk-agent:error", {
requestId, error: `Unknown SDK backend: ${sdkBackend}`,
@@ -752,8 +768,11 @@ function registerSdkStreamHandlers(ctx) {
}
const abortController = new AbortController();
const detachDelegatedCancellation = delegatedAgent
? mcpServerBridge.onPluginAgentRelease(chatSessionId, delegationTag, () => abortController.abort()) : null;
sdkActiveStreams.set(requestId, abortController);
sdkRequestSessions.set(requestId, chatSessionId);
if (delegatedAgent) sdkRequestRuntimes.set(requestId, { backendKey, delegationTag, senderId: event.sender.id });
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
let privacyEnabled = false;
@@ -776,6 +795,7 @@ function registerSdkStreamHandlers(ctx) {
mcpServerBridge,
chatSessionId,
toolIntegrationMode: effectiveMode,
delegationTag,
});
// NETCATTY_CLAUDE_SETTINGS is a netcatty marker carrying the claude SDK
@@ -850,6 +870,7 @@ function registerSdkStreamHandlers(ctx) {
const sessionRuntime = backendKey === "grok" ? grokRuntime : codexRuntime;
sdkRequestRuntimes.set(requestId, {
backendKey,
...(delegatedAgent ? { delegationTag, senderId: event.sender.id } : {}),
codexRuntime,
grokRuntime,
sessionRuntime,
@@ -1026,9 +1047,14 @@ function registerSdkStreamHandlers(ctx) {
chatSessionId,
// Drivers recheck after their own asynchronous startup, at the
// final Netcatty-owned prompt handoff (including disabled turns).
assertSessionValid: () => privacyService.checkSession(chatSessionId),
assertSessionValid: () => {
if (delegatedAgent) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
abortController.signal.throwIfAborted();
return privacyService.checkSession(chatSessionId);
},
delegatedAgent,
...promptPlacement,
cwd: cwd || process.cwd(),
cwd: delegatedCwd || cwd || process.cwd(),
model: model || undefined,
permissionMode: permissionMode || "confirm",
env,
@@ -1086,6 +1112,7 @@ function registerSdkStreamHandlers(ctx) {
emitter.emitError(err?.message || String(err));
return { ok: false, error: err?.message || String(err) };
} finally {
detachDelegatedCancellation?.();
sdkActiveStreams.delete(requestId);
sdkRequestSessions.delete(requestId);
sdkRequestRuntimes.delete(requestId);
@@ -1388,7 +1415,19 @@ function registerSdkStreamHandlers(ctx) {
ipcMain.handle("netcatty:ai:sdk-agent:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const effectiveChatSessionId = chatSessionId || sdkRequestSessions.get(requestId);
const ownedChatSessionId = sdkRequestSessions.get(requestId);
// Caller text cannot select the legacy route for a delegated request.
if (ownedChatSessionId && chatSessionId && ownedChatSessionId !== chatSessionId) return { ok: false, error: "Agent request ownership mismatch" };
const effectiveChatSessionId = ownedChatSessionId || chatSessionId;
if (mcpServerBridge.isPluginAgentConversation?.(effectiveChatSessionId)) {
const native = sdkRequestRuntimes.get(requestId);
if (!native?.delegationTag || native.senderId !== event.sender.id
|| sdkRequestSessions.get(requestId) !== effectiveChatSessionId) return { ok: false, error: "Agent turn unavailable" };
try { mcpServerBridge.assertPluginAgentCaller(effectiveChatSessionId, event.sender.id, native.delegationTag); }
catch { return { ok: false, error: "Agent turn unavailable" }; }
sdkActiveStreams.get(requestId)?.abort();
return { ok: true };
}
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(effectiveChatSessionId);
@@ -1405,6 +1444,9 @@ function registerSdkStreamHandlers(ctx) {
ipcMain.handle("netcatty:ai:sdk-agent:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
// This legacy route has no per-request identity. Delegated lifecycle is
// owned by main's exact-turn lease, never a delayed chat-wide UI cleanup.
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) return { ok: false, error: "Delegated Agent cleanup is main-owned" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(chatSessionId);
@@ -2192,3 +2192,85 @@ test("SDK adapter passes a live privacy guard through asynchronous driver startu
assert.equal(sent, false);
assert.equal(h.ctx.sdkActiveStreams.size, 0);
});
test("delegated Agent entry rejects unverified runtimes and Skills before native/environment work", async () => {
let validated = 0;
const { handlers } = registerWithStubbedCtx({
mcpServerBridge: {
isPluginAgentConversation: () => true,
assertPluginAgentCaller: () => {},
getPluginAgentMcpConfiguration: () => { validated++; throw Error("No current delegation"); },
},
});
const handler = handlers.get("netcatty:ai:sdk-agent:stream");
for (const sdkBackend of ["codex", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo", "arbitrary-cli"]) {
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend, toolIntegrationMode: "mcp",
}), /not eligible/);
}
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "skills",
}), /not eligible/);
assert.equal(validated, 0);
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "mcp",
}), /No current delegation/);
assert.equal(validated, 1);
});
test("delegated native cancellation is exact-request and cannot mark replacement chat work cancelled", async () => {
const broad = [];
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: () => true,
assertPluginAgentCaller: () => {},
setChatSessionCancelled: () => broad.push('chat'), cancelPtyExecsForSession: () => broad.push('pty'),
clearPendingApprovals: () => broad.push('approval'), cancelWorkerBackgroundJobsForSession: () => broad.push('worker'),
} });
const first = new AbortController(); const second = new AbortController();
const chat = 'plugin-agent:synthetic';
ctx.sdkActiveStreams.set('native-a', first); ctx.sdkRequestSessions.set('native-a', chat);
ctx.sdkRequestRuntimes.set('native-a', { delegationTag: 'tag-a', senderId: 7 });
ctx.sdkActiveStreams.set('native-b', second); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { delegationTag: 'tag-b', senderId: 7 });
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-a', chatSessionId: chat })).ok, true);
assert.equal(first.signal.aborted, true); assert.equal(second.signal.aborted, false);
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: '', chatSessionId: chat })).ok, false);
assert.equal((await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: chat })).ok, false);
assert.equal(second.signal.aborted, false); assert.deepEqual(broad, []);
});
test("legacy chat-wide cleanup cannot abort replacement delegated turn B or remove its metadata", async () => {
const effects = [];
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
cancelWorkerBackgroundJobsForSession: () => effects.push('worker'), cleanupScopedMetadata: async () => effects.push('metadata'),
} });
const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
const replacement = new AbortController();
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
const result = await handlers.get('netcatty:ai:sdk-agent:cleanup')({ sender: { id: 7 } }, { chatSessionId: chat, delegationTag: 'turn-a' });
assert.equal(replacement.signal.aborted, false); assert.equal(ctx.sdkActiveStreams.has('native-b'), true);
assert.equal(ctx.sdkRequestSessions.get('native-b'), chat); assert.equal(ctx.sdkRequestRuntimes.get('native-b').delegationTag, 'turn-b');
assert.deepEqual(effects, []); assert.equal(result.ok, false);
});
test("caller-supplied builtin chat cannot downgrade cancellation of a main-owned delegated request", async () => {
const effects = []; const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
assertPluginAgentCaller: (id, sender, tag) => { if (id !== chat || sender !== 7 || tag !== 'turn-b') throw Error('Unavailable'); },
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
clearPendingApprovals: () => effects.push('approval'), cancelWorkerBackgroundJobsForSession: () => effects.push('worker'),
} });
const replacement = new AbortController();
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
const result = await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: 'builtin-chat' });
assert.equal(replacement.signal.aborted, false); assert.equal(result.ok, false); assert.deepEqual(effects, []);
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-b', chatSessionId: chat })).ok, true);
assert.equal(replacement.signal.aborted, true);
});
+53 -9
View File
@@ -10,6 +10,8 @@ const net = require("node:net");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { PluginAgentMcpDelegations } = require("../plugins/agent/mcpDelegation.cjs");
const pluginAgentDelegations = new PluginAgentMcpDelegations();
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
@@ -1423,7 +1425,7 @@ function handleConnection(socket) {
consumedUntil = newlineIdx + 1;
searchFrom = consumedUntil;
if (!line.trim()) continue;
debugLog("Incoming line", line);
debugLog("Incoming message", { bytes: Buffer.byteLength(line) });
handleMessage(socket, line);
}
if (consumedUntil > 0) buffer = buffer.slice(consumedUntil);
@@ -1455,7 +1457,8 @@ async function handleMessage(socket, line) {
const isExternalToken = Boolean(
presentedToken && externalAuthToken && presentedToken === externalAuthToken,
);
if (method === "auth/verify" && (isCattyToken || isExternalToken)) {
const isDelegatedToken = method === "auth/verify" && pluginAgentDelegations.authenticate(presentedToken, socket);
if (method === "auth/verify" && (isCattyToken || isExternalToken || isDelegatedToken)) {
if (isExternalToken && !externalMcpActivityHook?.isEnabled?.()) {
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -1496,6 +1499,13 @@ async function handleMessage(socket, line) {
try {
const callParams = { ...(params || {}) };
const delegatedChat = pluginAgentDelegations.boundChat(socket);
if (delegatedChat) {
if (callParams.chatSessionId && callParams.chatSessionId !== delegatedChat) throw Error("Agent connection cannot change its session");
callParams.chatSessionId = delegatedChat;
} else if (pluginAgentDelegations.isReserved(callParams.chatSessionId)) {
throw Error("Delegated Agent credentials required");
}
// External-token sockets always operate under the reserved app-wide scope so
// callers cannot omit chatSessionId and widen access via scopedSessionIds.
if (externalMcpSockets.has(socket)) {
@@ -1531,7 +1541,7 @@ async function handleMessage(socket, line) {
managedPrivacyScopes.set(socket, callParams.chatSessionId);
}
result = await createManagedPrivacyBoundary(
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, context),
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, { ...context, externalSocket: socket }),
aiPrivacyService,
)(method, callParams);
}
@@ -1653,6 +1663,7 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
return invokeVaultAgentFn(...args);
},
evaluatePermissionWithGrants,
isDelegatedApproval: (context) => pluginAgentDelegations.isApproved(context),
get permissionMode() {
return permissionMode;
},
@@ -2148,7 +2159,17 @@ function getBuiltinRpcHandlerRegistry() {
}
async function dispatch(method, params, approvalContext = {}) {
debugLog("dispatch", { method, params, permissionMode });
if (pluginAgentDelegations.isReserved(params?.chatSessionId)) {
return pluginAgentDelegations.dispatch(method, params, approvalContext,
(approved) => dispatchCore(method, params, approved));
}
return dispatchCore(method, params, approvalContext);
}
async function dispatchCore(method, params, approvalContext = {}) {
const delegated = pluginAgentDelegations.isApproved(approvalContext);
if (delegated) debugLog("Delegated dispatch", { method });
else debugLog("dispatch", { method, params, permissionMode });
if (!method.startsWith("netcatty/")) {
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
@@ -2164,7 +2185,8 @@ async function dispatch(method, params, approvalContext = {}) {
pruneCompletedBackgroundJobs();
const approvalTarget = getApprovalTarget(params);
const permission = evaluatePermissionWithGrants({
const permission = delegated ? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
: evaluatePermissionWithGrants({
rpcMethod: method,
surface: CAPABILITY_SURFACES.BUILTIN,
permissionMode,
@@ -2284,10 +2306,12 @@ async function dispatch(method, params, approvalContext = {}) {
// ── Handler: getContext ──
async function handleGetContext(params) {
debugLog("handleGetContext:start", { params, sessionCount: sessions?.size || 0 });
const toolHints = buildMcpToolHints();
async function handleGetContext(params, approvalContext) {
const delegated = pluginAgentDelegations.isApproved(approvalContext);
debugLog("handleGetContext:start", delegated ? { delegated: true } : { params, sessionCount: sessions?.size || 0 });
const toolHints = delegated ? null : buildMcpToolHints();
if (!sessions) {
if (delegated) return { environment: "netcatty-terminal", description: "No host-selected terminal sessions are available.", hosts: [], hostCount: 0 };
return {
hosts: [],
instructions: "No sessions available.",
@@ -2333,7 +2357,7 @@ async function handleGetContext(params) {
description: "No hosts are available in the current scope.",
hosts: [],
hostCount: 0,
tools: toolHints,
...(delegated ? {} : { tools: toolHints }),
};
}
for (const [sessionId, session] of sessions.entries()) {
@@ -2383,6 +2407,12 @@ async function handleGetContext(params) {
}
}
if (pluginAgentDelegations.isApproved(approvalContext)) {
// Do not query or serialize app-wide tunnel/tool inventory for a delegated
// conversation. Its core-owned scope already selected the session metadata.
return { environment: "netcatty-terminal", description: "Use only these host-selected session IDs.",
hosts, hostCount: hosts.length };
}
let activePortForwardTunnels = [];
try {
activePortForwardTunnels = await listActivePortForwards();
@@ -2532,12 +2562,26 @@ const configAndCleanupApi = createConfigAndCleanupApi({
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
function cleanup() {
pluginAgentDelegations.shutdown();
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
shutdownHost();
}
// Keep identifier exports so Node 22's CJS namespace analysis also preserves the
// existing named exports consumed by renderer-side harness tests.
const registerPluginAgentDelegation = (...args) => pluginAgentDelegations.register(...args);
const getPluginAgentMcpConfiguration = (id, tag) => pluginAgentDelegations.configuration(id, tag);
const isPluginAgentConversation = (id) => pluginAgentDelegations.isReserved(id);
const assertPluginAgentCaller = (id, senderId, tag) => pluginAgentDelegations.assertCaller(id, senderId, tag);
const onPluginAgentRelease = (id, tag, cancel) => pluginAgentDelegations.onRelease(id, tag, cancel);
module.exports = {
registerPluginAgentDelegation,
getPluginAgentMcpConfiguration,
isPluginAgentConversation,
assertPluginAgentCaller,
onPluginAgentRelease,
init,
setCommandBlocklist,
getCommandBlocklist,
@@ -0,0 +1,110 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const { DelegatedAgentScopes } = require("../plugins/agent/delegatedScope.cjs");
const { buildInjectedMcpServers } = require("./aiBridge/sdk/injectMcp.cjs");
const bridge = require("./mcpServerBridge.cjs");
const privacy = require("../shared/aiPrivacyService.cjs");
test("actual main TCP gate binds delegated credentials, scope and policy at worker dispatch without global mode changes", { timeout: 10_000 }, async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-mcp-test-"));
const clients = []; const registrations = [];
let effects = 0; let inventoryRequests = 0; let decisions = 0; let allowed = true; let targetRevision = "v1";
let beforeWorker = () => {}; let authorize = async () => {};
t.after(() => {
for (const registration of registrations) registration.dispose();
for (const client of clients) client.close();
bridge.setPermissionMode("confirm"); bridge.cleanup();
privacy.configure({ enabled: false });
fs.rmSync(directory, { recursive: true, force: true });
});
privacy.configure({ enabled: false });
bridge.init({ sessions: new Map(), cliDiscoveryFilePath: path.join(directory, "discovery.json"),
terminalWorkerManager: { request: async (method, _payload, options) => {
if (method !== "netcatty:ai:exec") { inventoryRequests++; return []; }
beforeWorker(); options?.beforeExecute?.(); effects++;
return { ok: true, stdout: "synthetic output", stderr: "", exitCode: 0 };
} } });
bridge.setPermissionMode("observer");
const port = await bridge.getOrCreateHost();
const coreId = `plugin-agent:${randomUUID()}`;
const turnTag = randomUUID();
const owner = Object.freeze({ runtimeId: "r1", pluginId: "com.example.assistant", privateOwner: "opaque-source-a", sourceKey: "source-a" });
const context = {};
const scopes = new DelegatedAgentScopes({ resolveOwner: async () => owner, assertHostActive: async () => {},
authorizeAction: async () => { decisions++; await authorize(); if (!allowed) throw Error("Denied by source policy"); } });
const target = { kind: "terminal", id: "synthetic-a", owner: "window-7", revision: "v1",
isCurrent: (snapshot) => snapshot.revision === targetRevision };
const scope = await scopes.issue(context, { backend: { key: "configured-claude", revision: "1", isCurrent: () => true },
targets: [target], capabilities: ["terminal.execute", "session.environment"] });
let ticket = await scopes.begin(scope.handle, context);
registrations.push(bridge.registerPluginAgentDelegation(coreId, {
senderId: 7, turnTag, tools: ["get_environment", "terminal_execute"],
assertCurrent: () => { if (ticket.controller.signal.aborted || targetRevision !== target.revision) throw Error("Stale delegation"); },
dispatch: (method, params, execute) => {
const capabilityId = method === "netcatty/exec" ? "terminal.execute" : method === "netcatty/getContext" ? "session.environment" : "unsupported";
const targets = params.sessionId ? [{ ...target, id: params.sessionId }] : [];
return scopes.dispatch(ticket, context, { capabilityId, targets }, execute);
},
}));
bridge.updateSessionMetadata([{ sessionId: target.id, hostname: "fixture.invalid", label: "Synthetic A", protocol: "ssh" }], coreId);
const appToken = bridge.buildMcpServerConfig(port, [], coreId).env.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
const injected = await buildInjectedMcpServers({ mcpServerBridge: bridge, chatSessionId: coreId, toolIntegrationMode: "mcp", delegationTag: turnTag });
const environment = Object.fromEntries(injected[0].env.map((entry) => [entry.name, entry.value]));
assert.notEqual(environment.NETCATTY_MCP_TOKEN, appToken);
assert.equal(environment.NETCATTY_MCP_PERMISSION_MODE, "confirm");
assert.deepEqual(JSON.parse(environment.NETCATTY_MCP_DELEGATED_TOOLS), ["get_environment", "terminal_execute"]);
async function connect(token) {
const socket = net.createConnection({ host: "127.0.0.1", port });
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
const client = createNdjsonRpcClient({ socket }); clients.push(client);
await client.call("auth/verify", { token }); return client;
}
const app = await connect(appToken);
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }), /Delegated Agent credentials/);
const client = await connect(environment.NETCATTY_MCP_TOKEN);
await assert.rejects(client.call("netcatty/getContext", { chatSessionId: "builtin-chat" }), /cannot change/);
const selected = await client.call("netcatty/getContext", {});
assert.deepEqual(Object.keys(selected).sort(), ["description", "environment", "hostCount", "hosts"]);
assert.deepEqual(selected.hosts.map((item) => item.sessionId), ["synthetic-a"]);
assert.equal(inventoryRequests, 0, "delegated context must not query app-wide tunnel inventory");
await assert.rejects(client.call("netcatty/getStatus", {}), /outside the Agent delegation/);
const run = (sessionId = target.id) => client.call("netcatty/exec", { sessionId, command: "printf synthetic" });
const beforeDecision = decisions;
assert.equal((await run()).stdout, "synthetic output");
assert.equal(effects, 1); assert.equal(decisions, beforeDecision + 1); assert.equal(bridge.getPermissionMode(), "observer");
await assert.rejects(run("unselected-session")); assert.equal(effects, 1);
allowed = false; await assert.rejects(run(), /Denied by source policy/); assert.equal(effects, 1);
allowed = true;
const entered = new Promise((resolve) => { authorize = async () => { resolve(); await new Promise((done) => { authorize = done; }); }; });
const pending = run(); await entered;
scopes.finish(ticket); authorize(); await assert.rejects(pending); assert.equal(effects, 1);
ticket = await scopes.begin(scope.handle, context); authorize = async () => {};
beforeWorker = () => { targetRevision = "v2"; };
await assert.rejects(run()); assert.equal(effects, 1);
registrations[0].dispose();
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }));
assert.throws(() => bridge.getPluginAgentMcpConfiguration(coreId));
});
for (const sessions of [null, new Map()]) test(`delegated empty environment omits global tool inventory (${sessions === null ? "uninitialized" : "empty scope"})`, async (t) => {
bridge.init({ sessions });
const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
const registration = bridge.registerPluginAgentDelegation(id, { senderId: 7, turnTag: tag, tools: ["get_environment"],
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute() });
t.after(() => { registration.dispose(); bridge.cleanup(); });
const value = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag });
assert.equal(Object.hasOwn(value, "tools"), false); assert.deepEqual(value.hosts, []);
});
test("MCP bridge retains existing CJS namespace exports for actual Catty harness consumers", async () => {
const namespace = await import("./mcpServerBridge.cjs");
for (const name of ["init", "cleanup", "dispatchBuiltinRpc", "registerPluginAgentDelegation"]) assert.equal(typeof namespace[name], "function", name);
});
@@ -157,7 +157,9 @@ function createCapabilityRpcDispatcher(deps) {
hostname: host?.hostname || "",
};
}
const permission = evaluatePermissionWithGrants({
const permission = deps.isDelegatedApproval?.(approvalContext)
? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
: evaluatePermissionWithGrants({
rpcMethod,
surface,
permissionMode: deps.permissionMode,
+11 -2
View File
@@ -69,6 +69,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
// External MCP clients may keep a long-lived stdio process; re-read discovery so
// Settings → AI → Safety changes apply without restarting the client.
function readPermissionMode() {
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) return "confirm";
const discoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
if (discoveryPath) {
try {
@@ -319,12 +320,20 @@ server.resource(
);
// Start with core tools; load specialized catalog groups through load_netcatty_tools.
registerProgressiveMcpTools(server, {
const toolDependencies = {
rpcCall: exposedRpcCall,
scopeParams,
guardWriteOperation,
catalogDescription,
});
};
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) {
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
const { registerMcpTools } = require("../capabilities/codegen/mcpToolRegistry.cjs");
const names = JSON.parse(process.env.NETCATTY_MCP_DELEGATED_TOOLS);
if (!Array.isArray(names) || names.length > 128 || names.some((name) => typeof name !== "string")) throw Error("Invalid delegated tool profile");
const allowed = new Set(names);
registerMcpTools(server, toolDependencies, listMcpTools().filter((tool) => allowed.has(tool.mcpTool)));
} else registerProgressiveMcpTools(server, toolDependencies);
// ── Start ──
+47
View File
@@ -0,0 +1,47 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("../contractValidator.cjs");
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
const open = createDefinitionValidator("PluginAgentOpenParams");
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
assert.equal(open(params), true);
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
assert.equal(open({ ...params, [key]: value }), false, key);
}
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
}
});
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
const send = createDefinitionValidator("PluginAgentSendParams");
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
assert.equal(send(params), true);
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
assert.equal(send({ ...params, text: "a\0b" }), false);
assert.equal(send({ ...params, turnId: "builtin" }), false);
const event = createDefinitionValidator("PluginAgentEvent");
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
});
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
const chats = createDefinitionValidator("PluginAgentChatsResult");
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
assert.equal(chats({ items: [chat] }), true);
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
const history = createDefinitionValidator("PluginAgentHistoryResult");
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
});
@@ -0,0 +1,122 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash, randomUUID } = require("node:crypto");
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
const MAX_BYTES = 1_048_576;
const MAX_CONVERSATIONS = 64;
// Core-owned AISession documents, outside plugin filesystem/storage roots.
// The caller must authenticate provenance before supplying the opaque owner.
// Native resume identity, compaction and tool history stay in this repository;
// only a separate public projection is returned through the plugin API.
class PluginAgentConversationStore {
constructor(directory) {
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
this.directory = directory;
this.pending = new Map();
}
#ownerDirectory(owner) {
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
}
#filename(owner, id) {
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
return path.join(this.#ownerDirectory(owner), `${id}.json`);
}
async #prepare(owner) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
const directory = this.#ownerDirectory(owner);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
return directory;
}
#validate(record) {
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|| !Array.isArray(record.messages) || record.messages.length > 200
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
const text = JSON.stringify(record);
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
return text;
}
async #serial(owner, operation) {
const key = this.#ownerDirectory(owner);
const previous = this.pending.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(operation);
this.pending.set(key, current);
try { return await current; }
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
}
async list(owner, assertCurrent) {
await assertCurrent();
const directory = await this.#prepare(owner);
const files = await fs.readdir(directory);
const records = [];
for (const file of files) {
const id = file.slice(0, -5);
if (!file.endsWith(".json") || !UUID.test(id)) continue;
if (records.length >= MAX_CONVERSATIONS) break;
const record = await this.read(owner, id, assertCurrent);
if (record) records.push(record);
}
await assertCurrent();
return records.sort((a, b) => b.updatedAt - a.updatedAt);
}
async read(owner, id, assertCurrent) {
await assertCurrent();
const filename = this.#filename(owner, id);
await this.#prepare(owner);
let file;
try {
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
const { constants } = require("node:fs");
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
const record = JSON.parse(await file.readFile("utf8"));
this.#validate(record);
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
await assertCurrent();
return record;
} catch (error) {
if (error.code === "ENOENT") { await assertCurrent(); return null; }
throw error;
} finally { await file?.close(); }
}
async save(owner, record, assertCurrent) {
// Clone/serialize before any await so caller mutation cannot change commit.
const text = this.#validate(record);
const id = record.conversationRef;
return this.#serial(owner, async () => {
await assertCurrent();
const directory = await this.#prepare(owner);
const filename = this.#filename(owner, id);
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
try {
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
await assertCurrent();
await fs.rename(temporary, filename);
} finally { await fs.rm(temporary, { force: true }); }
});
}
}
module.exports = { PluginAgentConversationStore };
@@ -0,0 +1,68 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
const current = async () => {};
function record() {
const conversationRef = randomUUID();
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
}
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
const { root, store } = await fixture(t); const chat = record();
await store.save(owner("a"), chat, current);
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.doesNotMatch(directory, /opaque|same-plugin/);
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
assert.deepEqual(await store.list(owner("b"), current), []);
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
});
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const next = { ...chat, title: "Must not commit" }; let checks = 0;
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
});
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
});
test("symlinked history roots and files do not become history authority", async (t) => {
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
await fs.rm(filename); await fs.symlink(unrelated, filename);
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
});
+180
View File
@@ -0,0 +1,180 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
const key = (target) => JSON.stringify([target.kind, target.id]);
const isRequestCancellation = (error, context) => context.signal?.aborted === true
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
// supplies snapshots after resolving configured backends and choosing targets.
// Plugins never supply owner, revision, capability policy or executable adapters.
class DelegatedAgentScopes {
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
}
this.resolveOwner = resolveOwner;
this.assertHostActive = assertHostActive;
this.authorizeAction = authorizeAction;
this.clock = clock;
this.scopes = new Map();
this.closed = false;
}
async #owner(context) {
if (this.closed) throw fail("unavailable");
const owner = await this.resolveOwner(context);
await this.assertHostActive(context);
context.signal?.throwIfAborted();
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
return owner;
}
// The snapshot is host-owned. A public schema must only accept returned handles.
async issue(context, { backend, targets, capabilities }) {
const owner = await this.#owner(context);
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|| !Array.isArray(capabilities) || capabilities.length > 128
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
const captured = new Map();
for (const target of targets) {
if (!target || !["terminal", "host", "note"].includes(target.kind)
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|| typeof target.revision !== "string" || !target.revision
|| typeof target.owner !== "string" || !target.owner
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
captured.set(key(target), Object.freeze({ ...target }));
}
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
this.#assertSnapshots(scope);
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
this.#assertSnapshots(scope);
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
this.scopes.set(scope.handle, scope);
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
}
#assertSnapshots(scope) {
// Current-owner/revision checks are synchronous registry lookups, so the
// final dispatch check and native call have no asynchronous focus gap.
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
for (const target of scope.targets.values()) {
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
}
}
async #resolve(handle, context) {
const scope = this.scopes.get(handle);
let owner;
try { owner = await this.#owner(context); }
catch (error) {
// Context identity comes from the authenticated host transport. Revoke
// only its own captured scope, never a victim referenced by another caller.
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
throw error;
}
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
try {
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
this.#assertSnapshots(scope);
} catch (error) {
// A failed individual RPC has no authority to revoke another turn. Real
// owner/target failure still invalidates the scope, even if a request also
// happens to be cancelled at that time.
if (!isRequestCancellation(error, context)) this.#revoke(scope);
throw error;
}
return scope;
}
async begin(handle, context) {
const scope = await this.#resolve(handle, context);
if (scope.activeTurn) throw fail("alreadyExists");
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
scope.activeTurn = ticket;
return ticket;
}
async assertTurn(ticket, context) {
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
const scope = await this.#resolve(ticket.scope.handle, context);
if (scope.activeTurn !== ticket) throw fail("cancelled");
ticket.controller.signal.throwIfAborted();
return scope;
}
// Called inside the actual tool dispatcher, after canonical argument/target
// resolution. It must not be used merely when displaying the model tool list.
async dispatch(ticket, context, action, execute) {
const scope = await this.assertTurn(ticket, context);
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
const targets = action.targets.map((target) => {
const captured = target && scope.targets.get(key(target));
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
return captured;
});
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
// The adapter combines plugin policy and native Agent restrictions once.
// No caller can select a process-global permission mode through this API.
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
await this.assertTurn(ticket, context);
this.#assertSnapshots(scope);
ticket.controller.signal.throwIfAborted();
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
assertCurrent: () => this.assertTurn(ticket, context) }));
await this.assertTurn(ticket, context);
return result;
}
finish(ticket) {
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
ticket.scope.activeTurn = null;
ticket.controller.abort();
return true;
}
async stop(handle, context) {
// Capture before the first await. A prior Stop cannot acquire authority over
// a turn that replaces the one the caller intended to stop.
const candidate = this.scopes.get(handle);
const ticket = candidate?.activeTurn;
const scope = await this.#resolve(handle, context);
return scope === candidate ? this.finish(ticket) : false;
}
#revoke(scope) {
this.finish(scope.activeTurn);
this.scopes.delete(scope.handle);
}
releaseRuntime(runtimeId) {
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
}
releasePlugin(pluginId) {
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
}
releaseAll() {
for (const scope of this.scopes.values()) this.#revoke(scope);
}
shutdown() {
this.closed = true;
this.releaseAll();
}
}
module.exports = { DelegatedAgentScopes };
@@ -0,0 +1,229 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
function deferred() {
let resolve;
const promise = new Promise((finish) => { resolve = finish; });
return { promise, resolve };
}
function fixture() {
let time = 1; let locked = false; let focus = "terminal-a";
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
let privateOwner = "source:opaque-owner-a:com.example.assistant";
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
let authorize = async () => {};
const context = { ...identity, assertActive: async () => {} };
const database = {
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: () => source,
privateOwner: () => privateOwner,
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
};
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
let backendRevision = "configured-1";
const currentTargets = new Map([
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
]);
const backend = { key: "host-configured-claude", revision: backendRevision,
isCurrent: (snapshot) => snapshot.revision === backendRevision };
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
assertHostActive: async () => { if (locked) throw Error("Locked"); },
authorizeAction: (...args) => authorize(...args), clock: () => time });
return { scopes, context, target, targets: currentTargets, database,
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
focusTo: (id) => { focus = id; }, focus: () => focus,
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
};
}
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
const f = fixture(); const ref = await f.issue();
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
const ticket = await f.scopes.begin(ref.handle, f.context);
f.focusTo("unselected-terminal");
let calls = 0;
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
assert.equal(f.focus(), "unselected-terminal");
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
assert.equal(calls, 1);
});
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
const f = fixture(); const ref = await f.issue();
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
f.runtimeTo("r2");
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
f.runtimeTo("r1"); f.replaceSource();
await assert.rejects(f.scopes.begin(ref.handle, f.context));
});
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
f.authorize(async (_context, owner, action) => {
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise;
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
release.resolve(); await assert.rejects(result);
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
});
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
}
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
assert.equal(decisions, 0); assert.equal(effects, 0);
});
test("Deny and revoked source during authorization never dispatch a tool", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let effects = 0;
f.authorize(async () => { throw Error("Denied"); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
f.authorize(async () => { f.replaceSource(); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
assert.equal(effects, 0);
});
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let effects = 0;
f.authorize(async (_context, _owner, _action, signal) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
const next = await f.scopes.begin(ref.handle, f.context);
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
});
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
});
await entered.promise;
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
else if (reason === "lock") f.lock();
else await f.scopes.stop(ref.handle, f.context);
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
});
}
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
});
test("source replacement detected during owner authentication aborts already-running native work", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
entered.resolve(signal); await release.promise; return "late native result";
});
const signal = await entered.promise;
try {
f.replaceSource();
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
assert.equal(f.scopes.scopes.size, 0);
} finally { release.resolve(); await assert.rejects(result); }
});
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let blocked = false;
const stopContext = { ...f.context, assertActive: async () => {
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; assert.equal(f.scopes.finish(first), true);
const second = await f.scopes.begin(ref.handle, f.context);
release.resolve();
assert.equal(await stopping, false);
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
for (const phase of [1, 5]) {
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
if (++checks === phase) { entered.resolve(); await release.promise; }
rpc.signal.throwIfAborted();
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; f.scopes.finish(first);
const second = await f.scopes.begin(ref.handle, f.context);
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
release.resolve();
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
}
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
f.database.getRetainedPluginSource = () => {
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
return { ...retained(), id: "different-retained-source" };
};
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
(error) => error !== rpc.signal.reason && error.code === -32007);
assert.equal(ticket.controller.signal.aborted, true);
assert.equal(f.scopes.scopes.size, 0);
});
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
assert.equal(ticket.controller.signal.aborted, false);
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
});
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
for (let i = 0; i < 8; i++) await f.issue();
await assert.rejects(f.issue());
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
f.scopes.shutdown(); await assert.rejects(f.issue());
});
+108
View File
@@ -0,0 +1,108 @@
"use strict";
const { randomBytes } = require("node:crypto");
const PREFIX = "plugin-agent:";
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
const toolsByMethod = new Map(listMcpTools().map((tool) => [tool.rpcMethod, tool.mcpTool]));
// Main-only per-turn MCP credentials. A managed Agent subprocess receives this
// credential instead of the app-wide token; no token crosses the plugin API.
class PluginAgentMcpDelegations {
constructor() {
this.sessions = new Map();
this.tokens = new Map();
this.sockets = new WeakMap();
this.approvedContexts = new WeakSet();
}
register(chatSessionId, { dispatch, assertCurrent, senderId, tools, turnTag }) {
if (typeof chatSessionId !== "string" || !chatSessionId.startsWith(PREFIX)
|| typeof dispatch !== "function" || typeof assertCurrent !== "function"
|| typeof turnTag !== "string" || !/^[0-9a-f-]{36}$/u.test(turnTag)
|| !Number.isSafeInteger(senderId) || !Array.isArray(tools) || tools.length > 128
|| tools.some((tool) => typeof tool !== "string" || !tool || tool.length > 128)
|| this.sessions.has(chatSessionId) || this.sessions.size >= 64) throw Error("Invalid Agent delegation");
const token = randomBytes(32).toString("hex");
const entry = { chatSessionId, turnTag, token, dispatch, assertCurrent, senderId, tools: Object.freeze([...tools]), sockets: new Set(), cancellations: new Set() };
this.sessions.set(chatSessionId, entry); this.tokens.set(token, entry);
return Object.freeze({ dispose: () => { if (this.sessions.get(chatSessionId) === entry) this.release(chatSessionId); } });
}
#assert(entry) {
if (!entry || this.sessions.get(entry.chatSessionId) !== entry) throw Error("Agent delegation unavailable");
try { entry.assertCurrent(); }
catch (error) {
// This callback is a synchronous main-owned authority check, not an RPC
// signal. Discovering revocation must abort already-running native work.
if (this.sessions.get(entry.chatSessionId) === entry) this.release(entry.chatSessionId);
throw error;
}
return entry;
}
authenticate(token, socket) {
const entry = this.tokens.get(token);
if (!entry) return false;
try { this.#assert(entry); } catch { return false; }
this.sockets.set(socket, entry); entry.sockets.add(socket);
socket.once?.("close", () => entry.sockets.delete(socket));
return true;
}
boundChat(socket) {
const entry = this.sockets.get(socket);
return entry ? this.#assert(entry).chatSessionId : null;
}
configuration(chatSessionId, turnTag) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
return Object.freeze({ token: entry.token, tools: entry.tools });
}
has(chatSessionId) { return this.sessions.has(chatSessionId); }
assertCaller(chatSessionId, senderId, turnTag) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.senderId !== senderId || entry.turnTag !== turnTag) throw Error("Agent caller unavailable");
}
onRelease(chatSessionId, turnTag, cancel) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
if (typeof cancel !== "function") throw Error("Native cancellation callback required");
entry.cancellations.add(cancel);
return () => entry.cancellations.delete(cancel);
}
isReserved(chatSessionId) { return typeof chatSessionId === "string" && chatSessionId.startsWith(PREFIX); }
isApproved(context) { return this.approvedContexts.has(context); }
async dispatch(method, params, context, execute) {
const entry = this.#assert(this.sessions.get(params.chatSessionId));
if (!entry.tools.includes(toolsByMethod.get(method))) throw Error("Tool is outside the Agent delegation");
const socketEntry = context.externalSocket && this.sockets.get(context.externalSocket);
if (socketEntry !== entry && (context.trustedAgentSenderId !== entry.senderId || context.trustedAgentTurnTag !== entry.turnTag)) throw Error("Agent caller unavailable");
if (socketEntry && socketEntry !== entry) throw Error("Agent connection cannot change its session");
return entry.dispatch(method, params, async () => {
this.#assert(entry);
const approved = { ...context, beforeExecute: () => {
this.#assert(entry); context.beforeExecute?.();
} };
this.approvedContexts.add(approved);
try { return await execute(approved); }
finally { this.approvedContexts.delete(approved); }
});
}
release(chatSessionId) {
const entry = this.sessions.get(chatSessionId);
if (!entry) return;
this.sessions.delete(chatSessionId); this.tokens.delete(entry.token);
for (const cancel of entry.cancellations) { try { cancel(); } catch {} }
entry.cancellations.clear();
for (const socket of entry.sockets) { try { socket.destroy(); } catch {} }
entry.sockets.clear();
}
shutdown() { for (const id of this.sessions.keys()) this.release(id); }
}
module.exports = { PluginAgentMcpDelegations };
@@ -0,0 +1,60 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { randomUUID } = require("node:crypto");
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
const options = (turnTag, overrides = {}) => ({ turnTag, senderId: 7, tools: ["get_environment"],
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute(), ...overrides });
function socket() {
const value = new EventEmitter(); value.destroyed = false;
value.destroy = () => { value.destroyed = true; value.emit("close"); }; return value;
}
test("old turn disposer, credentials and late cancellation registration cannot acquire replacement turn B", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`;
const a = randomUUID(); const b = randomUUID();
const first = registry.register(id, options(a));
const tokenA = registry.configuration(id, a).token; const connectionA = socket();
assert.equal(registry.authenticate(tokenA, connectionA), true);
let cancelledA = 0; let cancelledB = 0;
registry.onRelease(id, a, () => cancelledA++); first.dispose();
const second = registry.register(id, options(b)); registry.onRelease(id, b, () => cancelledB++);
first.dispose(); assert.equal(cancelledB, 0); assert.equal(cancelledA, 1);
assert.equal(connectionA.destroyed, true);
assert.equal(registry.authenticate(tokenA, socket()), false);
assert.throws(() => registry.configuration(id, a));
assert.throws(() => registry.onRelease(id, a, () => cancelledB++));
assert.throws(() => registry.assertCaller(id, 7, a));
assert.throws(() => registry.assertCaller(id, 99, b));
assert.doesNotThrow(() => registry.assertCaller(id, 7, b));
assert.equal(cancelledB, 0); second.dispose(); assert.equal(cancelledB, 1);
});
test("discovered owner revocation aborts native work and one faulty callback cannot interrupt cleanup", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
let current = true;
registry.register(id, options(tag, { assertCurrent: () => { if (!current) throw Error("Source revoked"); } }));
const token = registry.configuration(id, tag).token; const connection = socket(); registry.authenticate(token, connection);
const native = new AbortController();
registry.onRelease(id, tag, () => { throw Error("Faulty cleanup listener"); });
registry.onRelease(id, tag, () => native.abort());
current = false; assert.throws(() => registry.assertCaller(id, 7, tag), /Source revoked/);
assert.equal(native.signal.aborted, true); assert.equal(connection.destroyed, true);
assert.equal(registry.has(id), false); assert.equal(registry.authenticate(token, socket()), false);
});
test("tool profile and trusted caller tag are enforced before invoking the host policy callback", async () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
let decisions = 0; let effects = 0;
registry.register(id, options(tag, { dispatch: (_method, _params, execute) => { decisions++; return execute(); } }));
await assert.rejects(registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: randomUUID() }, () => effects++));
await assert.rejects(registry.dispatch("netcatty/exec", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, () => effects++));
assert.equal(decisions, 0); assert.equal(effects, 0);
await registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, (context) => {
assert.equal(registry.isApproved(context), true); context.beforeExecute(); effects++;
});
assert.equal(decisions, 1); assert.equal(effects, 1);
registry.shutdown();
});
@@ -1771,6 +1771,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -1815,6 +1817,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -7186,6 +7190,402 @@
}
},
"additionalProperties": false
},
"PluginAgentFeature": {
"const": "ai.agent-sessions"
},
"PluginAgentBackendKind": {
"enum": [
"catty",
"claude",
"codex",
"copilot",
"cursor",
"codebuddy",
"opencode",
"grok",
"mimo"
]
},
"PluginAgentBackend": {
"type": "object",
"required": [
"backendRef",
"label",
"backend",
"status",
"reason"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"status": {
"enum": [
"supported",
"unsupported",
"unavailable"
]
},
"reason": {
"enum": [
"ready",
"not-configured",
"native-boundary-unverified",
"configuration-unavailable"
]
}
},
"additionalProperties": false
},
"PluginAgentBackendsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentBackend"
}
}
},
"additionalProperties": false
},
"PluginAgentTargetSummary": {
"type": "object",
"required": [
"title",
"kind"
],
"properties": {
"title": {
"type": "string",
"maxLength": 160
},
"kind": {
"const": "terminal"
}
},
"additionalProperties": false
},
"PluginAgentTargetsResult": {
"type": "object",
"required": [
"selectionHandle",
"expiresAt",
"items"
],
"properties": {
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentTargetSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentOpenParams": {
"type": "object",
"required": [
"backendRef",
"selectionHandle"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatRef": {
"type": "object",
"required": [
"chatHandle"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatResult": {
"type": "object",
"required": [
"chatHandle",
"conversationRef",
"backendLabel",
"expiresAt"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
},
"backendLabel": {
"type": "string",
"maxLength": 160
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatSummary": {
"type": "object",
"required": [
"conversationRef",
"title",
"backend",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"updatedAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/PluginAgentChatSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentSendParams": {
"type": "object",
"required": [
"chatHandle",
"text"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"PluginAgentTurnRef": {
"type": "object",
"required": [
"turnHandle"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentTurnResult": {
"type": "object",
"required": [
"turnHandle",
"expiresAt"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentMessage": {
"type": "object",
"required": [
"role",
"text"
],
"properties": {
"role": {
"enum": [
"user",
"assistant"
]
},
"text": {
"type": "string",
"maxLength": 32768
}
},
"additionalProperties": false
},
"PluginAgentHistoryResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/PluginAgentMessage"
}
}
},
"additionalProperties": false
},
"PluginAgentEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"maxLength": 4096
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"capabilityId",
"state"
],
"properties": {
"kind": {
"const": "activity"
},
"capabilityId": {
"type": "string",
"maxLength": 160
},
"state": {
"enum": [
"started",
"completed",
"denied"
]
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"inputTokens",
"outputTokens"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0
},
"outputTokens": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"reason"
],
"properties": {
"kind": {
"const": "end"
},
"reason": {
"enum": [
"completed",
"cancelled",
"unavailable",
"budget-exceeded"
]
}
},
"additionalProperties": false
}
]
}
}
}
+5
View File
@@ -0,0 +1,5 @@
"use strict";
require("./agent/delegatedScope.test.cjs");
require("./agent/conversationStore.test.cjs");
require("./agent/contract.test.cjs");
require("./agent/mcpDelegation.test.cjs");
+10 -10
View File
@@ -1993,17 +1993,17 @@ function createPreloadApi(ctx) {
aiAllowlistAddHost: async (baseURL) => {
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
},
aiExec: async (sessionId, command, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
aiExec: async (sessionId, command, chatSessionId, delegationTag) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId, ...(delegationTag ? { delegationTag } : {}) });
},
aiCattyCancelExec: async (chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
aiCattyCancelExec: async (chatSessionId, delegationTag) => {
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId, ...(delegationTag ? { delegationTag } : {}) });
},
aiSetChatSessionCancelled: async (chatSessionId, cancelled = true) => {
return ipcRenderer.invoke("netcatty:ai:chat-session:set-cancelled", { chatSessionId, cancelled });
aiSetChatSessionCancelled: async (chatSessionId, cancelled = true, delegationTag) => {
return ipcRenderer.invoke("netcatty:ai:chat-session:set-cancelled", { chatSessionId, cancelled, ...(delegationTag ? { delegationTag } : {}) });
},
aiCapability: async (rpcMethod, params, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:capability", { rpcMethod, params, chatSessionId });
aiCapability: async (rpcMethod, params, chatSessionId, delegationTag) => {
return ipcRenderer.invoke("netcatty:ai:capability", { rpcMethod, params, chatSessionId, ...(delegationTag ? { delegationTag } : {}) });
},
aiDiscoverAgents: async (options) => {
return ipcRenderer.invoke("netcatty:ai:agents:discover", options);
@@ -2064,8 +2064,8 @@ function createPreloadApi(ctx) {
aiPrivacyStatus: async () => ipcRenderer.invoke("netcatty:ai:privacy:status"),
aiPrivacyTransform: async (request) => ipcRenderer.invoke("netcatty:ai:privacy:transform", request),
aiPrivacyClearSession: async (chatSessionId) => ipcRenderer.invoke("netcatty:ai:privacy:clear", chatSessionId),
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
aiMcpUpdateSessions: async (sessions, chatSessionId, delegationTag) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId, ...(delegationTag ? { delegationTag } : {}) });
},
aiMcpUpdateLiveSessions: async (sessions) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-live-sessions", { sessions });
+1 -1
View File
@@ -148,7 +148,7 @@ export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string, delegationTag?: string) => Promise<unknown>;
aiMcpUpdateAttachments?: (
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
chatSessionId?: string,
+4 -2
View File
@@ -22,6 +22,7 @@ export interface NetcattyBridge {
sessionId: string,
command: string,
chatSessionId?: string,
delegationTag?: string,
): Promise<{
ok: boolean;
stdout?: string;
@@ -36,12 +37,13 @@ export interface NetcattyBridge {
* window if the user clicks Stop after we've already dispatched
* `aiExec` but before the main process has registered it.
*/
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
aiCattyCancelExec?(chatSessionId: string, delegationTag?: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean, delegationTag?: string): Promise<{ ok: boolean; error?: string }>;
aiCapability?(
rpcMethod: string,
params: Record<string, unknown>,
chatSessionId?: string,
delegationTag?: string,
): Promise<unknown>;
}
@@ -5,6 +5,25 @@ import { z } from 'zod';
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
import type { OpenAIChatAssistantFields } from './providerContinuation';
test('delegated bridge fetch rejects absent or substituted turn identity before transport', async (t) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
let requests = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
aiFetch: async () => { requests++; throw Error('Unexpected fixture transport'); },
aiChatStream: async () => { requests++; throw Error('Unexpected fixture transport'); },
} } });
t.after(() => { if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window'); });
const id = '11111111-1111-4111-8111-111111111111';
const tag = '22222222-2222-4222-8222-222222222222';
for (const requestIdPrefix of [undefined, 'sdk_', `agent-model_${tag}_${tag}_`]) {
const fetch = createBridgeFetchForSDK('synthetic-provider', { requireBridge: true, chatSessionId: `plugin-agent:${id}`, requestIdPrefix });
await assert.rejects(fetch('http://fixture.invalid/v1/chat/completions', {
method: 'POST', body: JSON.stringify({ stream: true, messages: [] }),
}), /Delegated model request identity/);
}
assert.equal(requests, 0);
});
test('buffers stream events emitted before the Response stream starts', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
+5 -1
View File
@@ -496,7 +496,11 @@ export function createBridgeFetchForSDK(
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `${requestContext?.requestIdPrefix ?? ''}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const delegatedId = /^plugin-agent:([0-9a-f-]{36})$/.exec(requestContext?.chatSessionId ?? '')?.[1];
if (delegatedId && (!/^agent-model_[0-9a-f-]{36}_[0-9a-f-]{36}_$/.test(requestContext?.requestIdPrefix ?? '')
|| !requestContext?.requestIdPrefix?.startsWith(`agent-model_${delegatedId}_`))) throw Error('Delegated model request identity required');
const prefix = requestContext?.requestIdPrefix ?? '';
const requestId = `${prefix}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
@@ -1698,6 +1698,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -1742,6 +1744,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -5004,6 +5008,402 @@
}
},
"additionalProperties": false
},
"PluginAgentFeature": {
"const": "ai.agent-sessions"
},
"PluginAgentBackendKind": {
"enum": [
"catty",
"claude",
"codex",
"copilot",
"cursor",
"codebuddy",
"opencode",
"grok",
"mimo"
]
},
"PluginAgentBackend": {
"type": "object",
"required": [
"backendRef",
"label",
"backend",
"status",
"reason"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"status": {
"enum": [
"supported",
"unsupported",
"unavailable"
]
},
"reason": {
"enum": [
"ready",
"not-configured",
"native-boundary-unverified",
"configuration-unavailable"
]
}
},
"additionalProperties": false
},
"PluginAgentBackendsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentBackend"
}
}
},
"additionalProperties": false
},
"PluginAgentTargetSummary": {
"type": "object",
"required": [
"title",
"kind"
],
"properties": {
"title": {
"type": "string",
"maxLength": 160
},
"kind": {
"const": "terminal"
}
},
"additionalProperties": false
},
"PluginAgentTargetsResult": {
"type": "object",
"required": [
"selectionHandle",
"expiresAt",
"items"
],
"properties": {
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentTargetSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentOpenParams": {
"type": "object",
"required": [
"backendRef",
"selectionHandle"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatRef": {
"type": "object",
"required": [
"chatHandle"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatResult": {
"type": "object",
"required": [
"chatHandle",
"conversationRef",
"backendLabel",
"expiresAt"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
},
"backendLabel": {
"type": "string",
"maxLength": 160
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatSummary": {
"type": "object",
"required": [
"conversationRef",
"title",
"backend",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"updatedAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/PluginAgentChatSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentSendParams": {
"type": "object",
"required": [
"chatHandle",
"text"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"PluginAgentTurnRef": {
"type": "object",
"required": [
"turnHandle"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentTurnResult": {
"type": "object",
"required": [
"turnHandle",
"expiresAt"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentMessage": {
"type": "object",
"required": [
"role",
"text"
],
"properties": {
"role": {
"enum": [
"user",
"assistant"
]
},
"text": {
"type": "string",
"maxLength": 32768
}
},
"additionalProperties": false
},
"PluginAgentHistoryResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/PluginAgentMessage"
}
}
},
"additionalProperties": false
},
"PluginAgentEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"maxLength": 4096
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"capabilityId",
"state"
],
"properties": {
"kind": {
"const": "activity"
},
"capabilityId": {
"type": "string",
"maxLength": 160
},
"state": {
"enum": [
"started",
"completed",
"denied"
]
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"inputTokens",
"outputTokens"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0
},
"outputTokens": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"reason"
],
"properties": {
"kind": {
"const": "end"
},
"reason": {
"enum": [
"completed",
"cancelled",
"unavailable",
"budget-exceeded"
]
}
},
"additionalProperties": false
}
]
}
}
}
@@ -486,7 +486,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
export type NetworkCredentialLeaseFeature = "network.credential-lease";
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type NullableRpcId = (RpcId) | (null);
@@ -554,6 +554,100 @@ export type PermissionSet = {
optional?: Array<PermissionDeclaration>;
};
export type PluginAgentBackend = {
backendRef: string;
label: string;
backend: PluginAgentBackendKind;
status: "supported" | "unsupported" | "unavailable";
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
};
export type PluginAgentBackendKind = "catty" | "claude" | "codex" | "copilot" | "cursor" | "codebuddy" | "opencode" | "grok" | "mimo";
export type PluginAgentBackendsResult = {
items: Array<PluginAgentBackend>;
};
export type PluginAgentChatRef = {
chatHandle: string;
};
export type PluginAgentChatResult = {
chatHandle: string;
conversationRef: string;
backendLabel: string;
expiresAt: number;
};
export type PluginAgentChatsResult = {
items: Array<PluginAgentChatSummary>;
};
export type PluginAgentChatSummary = {
conversationRef: string;
title: string;
backend: PluginAgentBackendKind;
updatedAt: number;
};
export type PluginAgentEvent = ({
kind: "text";
text: string;
}) | ({
kind: "activity";
capabilityId: string;
state: "started" | "completed" | "denied";
}) | ({
kind: "usage";
inputTokens: number;
outputTokens: number;
}) | ({
kind: "end";
reason: "completed" | "cancelled" | "unavailable" | "budget-exceeded";
});
export type PluginAgentFeature = "ai.agent-sessions";
export type PluginAgentHistoryResult = {
items: Array<PluginAgentMessage>;
};
export type PluginAgentMessage = {
role: "user" | "assistant";
text: string;
};
export type PluginAgentOpenParams = {
backendRef: string;
selectionHandle: string;
conversationRef?: string;
};
export type PluginAgentSendParams = {
chatHandle: string;
text: string;
};
export type PluginAgentTargetsResult = {
selectionHandle: string;
expiresAt: number;
items: Array<PluginAgentTargetSummary>;
};
export type PluginAgentTargetSummary = {
title: string;
kind: "terminal";
};
export type PluginAgentTurnRef = {
turnHandle: string;
};
export type PluginAgentTurnResult = {
turnHandle: string;
expiresAt: number;
};
export type PluginAIBooleanResult = {
ok: boolean;
};
@@ -839,7 +933,7 @@ export type PluginNetworkResponse = {
};
};
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type PluginPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type PluginSelectedContextContent = {
handle: string;
+1
View File
@@ -55,6 +55,7 @@ export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MA
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
export const PLUGIN_AGENT_SESSIONS_FEATURE = "ai.agent-sessions" as const satisfies import("./generated/plugin-contract.js").PluginAgentFeature;
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
+14
View File
@@ -685,6 +685,19 @@ export interface PluginSelectedContextClient {
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
}
/** Configurations, targets and native resume IDs stay in the trusted host. */
export interface PluginAgentClient {
listBackends(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentBackendsResult>;
selectTargets(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentTargetsResult>;
listChats(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatsResult>;
openChat(params: import("@netcatty/plugin-contract").PluginAgentOpenParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatResult>;
send(params: import("@netcatty/plugin-contract").PluginAgentSendParams): Promise<import("@netcatty/plugin-contract").PluginAgentTurnResult>;
read(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAgentEvent>;
history(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAgentHistoryResult>;
stop(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
closeChat(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
}
export interface PluginByteConnection extends Disposable {
readonly endpoint: string;
readonly readable: PluginReadableByteStream;
@@ -784,6 +797,7 @@ export interface PluginContext {
readonly credentials: PluginCredentialBroker;
readonly network: PluginNetworkClient;
readonly ai: PluginAIClient;
readonly agent: PluginAgentClient;
readonly selectedContext: PluginSelectedContextClient;
readonly filesystem: PluginFilesystemClient;
readonly companions: PluginCompanionService;