Compare commits

..
74 changed files with 4536 additions and 100 deletions

No files matched your search

+2
View File
@@ -27,6 +27,7 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -858,6 +859,7 @@ function AppViewInner({ domains }: AppViewProps) {
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
// All inventory remains in trusted application UI. Only confirmed metadata is
// passed to the main-owned worker bindings; focus never determines a target.
export function usePluginAgentHost(sessions: TerminalSession[]) {
const latest = useRef(sessions); latest.current = sessions;
const [choice, setChoice] = useState<Choice | null>(null);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId); return;
}
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request, controller.signal).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) runtime.invalidateConfiguration();
};
window.addEventListener('pagehide', pageHide);
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
void bridge.ready(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
void bridge.ready(false).catch(() => {});
};
}, []);
return { choice };
}
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
// real model/Claude acceptance. No real shell, remote host or credentials.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); };
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('localStorage', dom.window.localStorage); install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client');
const { PluginAgentHost } = await import('./plugins/PluginAgentHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const keys = await import('../infrastructure/config/storageKeys');
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
const privacy = require('../electron/shared/aiPrivacyService.cjs');
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
const errors: string[] = []; const originalError = console.error;
let phase = 'setup';
// Flush the known Node sqlite import warning before observing safe categories.
await new Promise<void>((resolve) => setImmediate(resolve));
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
try {
privacy.configure({ enabled: false }); server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string'); const endpoint = `http://127.0.0.1:${address.port}/v1`;
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
} };
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
// The production stream function owns this map. Turn release below uses
// its actual main lease; direct SDK abort still calls the ordinary route.
activeStreams: new Map() };
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.match(request.requestId, /^agent-model_/);
}
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
} };
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
const prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
assert.match(element('usage').textContent!, /input tokens/);
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'restart-history';
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
await service.manager.setEnabled('com.netcatty.globalagent', false);
await service.manager.setEnabled('com.netcatty.globalagent', true);
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
assert.deepEqual(workerCalls, ['synthetic-b']);
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(directory, { recursive: true, force: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import { usePluginAgentHost } from '../../application/state/usePluginAgentHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAgentHost({ sessions }: { sessions: TerminalSession[] }) {
const { choice } = usePluginAgentHost(sessions);
const [selected, setSelected] = useState<string[]>([]);
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="" onOpenAutoFocus={() => setSelected([])}>
<DialogHeader><DialogTitle>Choose terminals for this Agent conversation</DialogTitle>
<DialogDescription>The conversation is independent of the active tab or workspace. Only the terminals selected here are eligible targets. Tool actions follow this plugin's Allow / Ask / Deny setting.</DialogDescription></DialogHeader>
<div className="max-h-72 space-y-2 overflow-y-auto">
{choice?.items.map((item) => <label key={item.sessionId} className="flex gap-2 text-sm">
<input type="checkbox" checked={selected.includes(item.sessionId)} disabled={selected.length >= 32 && !selected.includes(item.sessionId)}
onChange={(event) => setSelected((ids) => event.target.checked ? [...ids, item.sessionId] : ids.filter((id) => id !== item.sessionId))} />
<span>{item.label} <span className="text-muted-foreground">{item.hostname}</span></span>
</label>)}
{choice?.items.length === 0 && <p className="text-sm text-muted-foreground">No connected terminals. You can continue without targets.</p>}
</div>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(selected)}>Use selected terminals</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -20,6 +20,10 @@ export function PluginPermissionHost() {
</DialogHeader>
<p className="text-sm">{description.description}</p>
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
{prompt.displayContext?.action && <div className="space-y-1">
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{prompt.displayContext.action.text}</pre>
</div>}
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
@@ -68,11 +68,6 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
+74
View File
@@ -0,0 +1,74 @@
# Global Agent continuation: bounded QA recipe
This is a separate increment after the plugin platform foundation. The transport
review checkpoint is #4012 at `ea619555`; its isolated correction is `63f0572e0`.
The runtime continuation carries the correction through an ordinary feature-branch
merge. It is not merged to main or accepted for release.
## Reproduce the installable sample
From the exact runtime head reported in the handoff:
```sh
npm ci
npm run build:plugin-runtime-deps
node examples/plugins/global-agent/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/global-agent/.package --out /tmp/netcatty-global-agent.ncpkg
sha256sum /tmp/netcatty-global-agent.ncpkg
```
The reproducible sample SHA-256 is
`36993b1d566814d3b5304337fab73ff464e422d93ba52909e95e3ceedcad2a6a`.
Two independent package builds matched. Build scripts and tests are excluded
from the six-file installed package.
## Native fixture acceptance (parent-scheduled GUI only)
1. Use an isolated synthetic profile, no real Notes, hosts, histories or keys.
Start `node scripts/plugin-global-agent-provider-fixture.mjs --port 9218`.
It performs no inference or outbound request and logs no bodies/headers.
2. Through normal host settings, configure an OpenAI-compatible Chat Completions
provider at `http://127.0.0.1:9218/v1`, model `global-agent-fixture`, with the
fixture-only literal `FIXTURE_ONLY_API_MARKER`. Do not use real credentials,
paid services, allowlist edits, debugger injection or the builtin AI sidebar.
3. Install the `.ncpkg` in Settings → Plugins and open its contributed Global
Agent Chat view. Verify light/dark/accent updates using existing theme tokens.
4. Choose Catty. Choose no targets, or only owned synthetic terminal fixtures,
then Open conversation. The fixture's default tool is `get_environment`; it
never requests a terminal command. Record Send → streamed result/usage and
`GET /fixture/status` counters. This proves a deterministic pipeline, not
real model inference.
5. Send `fixture:wait`, wait for output, press Stop. Verify aborted count rises
once and no late text appears. Capture screenshot/video with fixture labels.
6. Exercise Actions Ask using the existing in-app policy dialog: approve and
deny the scoped environment tool separately. Actions Deny permits model-only
chat with no tools. Agent-use Deny prevents dispatch. A policy change revokes
existing handles; reselect/reopen explicitly before another Send.
7. Close/reopen and disable/re-enable only this synthetic plugin. Reselect its
targets and reopen its owned conversation. History should survive, while the
private per-open actor ID is fresh. At five-minute expiry, the sample asks for
explicit reselection and keeps the draft, without resending or renewing scope.
8. Revoke/disable or lock while streaming and verify cleanup/no late events.
Disconnect/re-own a selected synthetic worker during selection and a turn;
stale selections must fail instead of binding a replacement connection.
Do not activate a configured real Claude backend in this fixture recipe. Claude
MCP's code/handler boundary is covered with synthetic tests; authenticated service
and genuine CLI acceptance remain unverified and require separately approved
access. Other backends are explicitly unsupported/unavailable. No background
service, blanket OS/Vault access, merge or release is implied.
## Evidence and limits
- Installed Catty path uses actual `AgentRuntime`, driver, main provider admission,
source policy and worker dispatcher, with JSDOM/in-process plugin transport and
synthetic workers. It proves selected B dispatch, stream, Stop, revocation and
history after runtime restart. This is not a native BrowserWindow sandbox test.
- Main/core tests cover source/session/resource substitution, scope expiry,
approval waits, worker lifecycle, cancellation, late completion, stable source
budgets, source-isolated history and no key serialization. Filesystem API reads
cannot access the core history root. Explicitly granted terminal commands still
have that terminal's native OS authority.
- No GUI ran in this executor. Native screenshots/video and independent runtime
review remain parent-owned gates. The exact-head CI result must be reported
separately from focused local evidence.
+156 -8
View File
@@ -117,6 +117,79 @@ 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
@@ -151,11 +224,86 @@ 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.
### Separate runtime/sample implementation checkpoint
The runtime continuation wires the nine `context.agent` methods through the
registered default host, dedicated trusted-app preload/IPC and `PluginAgentHost`
mounted in `AppView`. It reuses the existing `AgentRuntime`, Catty/external turn
drivers, core `AISession` documents, compaction, model/provider transport, native
cancellation, selected worker epoch/generation checks and permission/audit engine.
No source database, Vault history or builtin conversation ownership change is
included. Plugin input cannot supply internal session IDs, source/owner, CLI/env,
MCP configuration or permission mode.
The first eligible profiles are configured Catty and Claude MCP. Other known
backends report unsupported/unavailable. Claude has builtin tools disabled,
strict MCP, no inherited settings sources, at most 12 native turns and no native
session persistence/resume; its existing driver rehydrates from core-owned history.
This code path has handler/driver/MCP fixture coverage. Authenticated Claude
service/native CLI acceptance has not run and must not be inferred from mocks.
Actions use one source-bound Allow / Ask / Deny decision path. Actions Deny permits
model-only conversation with an empty tool profile; Agent-use Deny blocks host
dispatch. Explicit target choice is independent of focus/workspace. Worker
lifecycle changes cancel a pending chooser and revoke stale target leases;
source/runtime/policy/lock invalidation aborts native work and rejects late events.
Core history is source/private-owner namespaced and read/write protected through
the plugin filesystem broker. A user-granted terminal command still has that
terminal's native OS authority; this is not an OS filesystem sandbox.
Bounds include six sends/source/minute, eight scopes/runtime and 64 total, 256
retained turn handles, 32 tool actions and 32 Catty HTTP calls per turn, 12 Catty
iterations, a 120-second turn lifetime, 60-second model HTTP/15-second streaming
idle deadlines, 128 KiB HTTP responses, 256 KiB projected turn output and 4 KiB
text events. Usage is projected; no hard total native-Claude token budget is
claimed. Handles expire after five minutes without silent target reselection.
The installable `examples/plugins/global-agent` sample provides backend/owned
conversation choice, explicit targets, history, Send/Stop and existing host theme
tokens. Its deterministic local provider fixture performs no model inference,
paid API calls, real terminal execution or request/body/header logging. The
installed Node/JSDOM pipeline test uses actual Catty orchestration, configured
HTTP admission and main dispatch to synthetic workers. It proves one selected-B
tool cycle, streamed result, Stop and revoked-handle denial; it is not native
browser sandbox or real model acceptance. A failed-before JSON-snapshot regression
is retained at `/tmp/netcatty-global-agent-snapshot-failed-before.log`.
Recorded validation: plugin runtime 732 passed/0 failed/1 skipped; affected group
105 passed/0 failed/3 skipped; examples 6 passed/0 failed/1 skipped; repaired setup
paths 284 passed. Build, codegen and lint passed before the final review corrections.
Typecheck is still red: 774 diagnostics on both unchanged foundation and this
continuation, with equal per-file/error-code counts; it is not a clean typecheck.
The repaired Node 22 full run reports 15,354 passed/9 failed/46 skipped. Eight are
missing xterm install patches, subsequently applied; the ninth is the transport
CJS namespace regression fixed separately. Exact final-head validation is pending.
The earlier pre-setup full run remains pending after its timed-out unpatched FIDO
test worker kept a socket open. Reading `/proc/319596/cwd` to confirm that worker's
identity was denied (EACCES); no signal was sent and no alternate route attempted.
Its log is retained without invented final totals. `pack:dir` first failed using
the unwritable default npm cache; a standard task-local cache repaired that step,
then native rebuild failed because installed `@electron/rebuild` hardcodes
`/home/agent/.electron-gyp` outside writable roots. No home override or sandbox
bypass was attempted. Native screenshots/video and independent runtime review
remain parent-owned gates; nothing is merged or released.
Final focused Node 22 validation passes 205 cases, including all nine failure
paths from the broad run after the documented setup/correction changes. The
installed pipeline additionally proves disable/re-enable, owned history reopening
and a fresh model-only turn under Actions Deny. `conversationRef` is durable;
each open receives a fresh private runtime actor so revoked privacy/native/tool
state cannot be revived. A typed, host-only close control clears its owned
runtime caches after that actor settles. Build, lint (seven existing warnings)
and generated drift checks pass. Typecheck still has the same 774 per-file/code
diagnostic counts as the unchanged foundation. Logs:
`/tmp/netcatty-global-agent-lifecycle-final-tests.log`,
`/tmp/netcatty-global-agent-lifecycle-final-build.log`, and
`/tmp/netcatty-global-agent-lifecycle-final-typecheck.log`.
The native recipe and reproducible package hash are in
[`global-agent-qa.md`](global-agent-qa.md). Remaining gates are independent
transport/runtime review, CI on the final integrated head, parent-scheduled native
screenshots/video, genuine Claude compatibility acceptance with separately
approved access, and native packaging on a machine with supported writable
rebuild caches. The initial full-run pending worker and previous red totals above
remain recorded. No GUI ownership, merge or release is granted to this executor.
+18 -2
View File
@@ -611,11 +611,22 @@ 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();
if (delegatedId) delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
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 +640,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 +660,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 +746,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 +811,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"));
+56
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,61 @@ 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: {
beginPluginAgentModelRequest: () => {},
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("delegated compaction fetch uses the same exact-turn cancellation", { timeout: 5_000 }, async (t) => {
const { randomUUID } = require("node:crypto"); const id = randomUUID(); const tag = randomUUID();
let live = true; let release; let detached = 0; let requests = 0;
let accepted; const started = new Promise((resolve) => { accepted = resolve; });
const ctx = await withCapturingServer(t, { mcpServerBridge: {
beginPluginAgentModelRequest: () => {},
assertPluginAgentCaller: (chatId, sender, actualTag) => {
assert.equal(chatId, `plugin-agent:${id}`); assert.equal(sender, 1);
if (!live || actualTag !== tag) throw Error("Agent unavailable");
},
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
} }, (res) => { requests++; res.writeHead(200, { "content-type": "application/json" }); res.write('{"synthetic":'); accepted(); });
try {
const invoke = () => ctx.ipcMain.handlers.get("netcatty:ai:fetch")({ sender: ctx.sender }, {
requestId: `agent-model_${id}_${tag}_fetch_fixture`, url: `${ctx.baseURL}/v1/chat/completions`, method: "POST",
headers: { "content-type": "application/json" }, body: '{"messages":[]}', providerId: "content-length",
});
const pending = invoke(); await started; assert.equal(ctx.received.body.toString(), '{"messages":[]}');
live = false; release(); assert.equal((await pending).ok, false); assert.equal(detached, 1);
assert.equal((await invoke()).ok, false); assert.equal(requests, 1); assert.equal(ctx.ipcMain.handlers.has("netcatty:ai:chat:cancel"), true);
} 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." };
}
+30 -4
View File
@@ -400,7 +400,7 @@ function registerProviderHandlers(ctx) {
});
// Non-streaming request (for model listing, validation, etc.)
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify, requestId }) => {
// Validate IPC sender — settings window needs this for model listing
if (!validateSenderOrSettings(event)) {
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
@@ -428,7 +428,25 @@ function registerProviderHandlers(ctx) {
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const delegated = typeof requestId === "string" && /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_fetch_/.exec(requestId);
if (requestId !== undefined && !delegated) return { ok: false, status: 0, data: "", error: "Delegated request identity invalid" };
const controller = delegated ? new AbortController() : null;
const delegatedBridge = delegated ? require("./mcpServerBridge.cjs") : null;
const assertDelegation = () => {
controller?.signal.throwIfAborted();
if (delegated) delegatedBridge.assertPluginAgentCaller(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
};
let detach; let totalTimer;
if (delegated) {
try {
assertDelegation();
delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
detach = delegatedBridge.onPluginAgentRelease(`plugin-agent:${delegated[1]}`, delegated[2], () => controller.abort());
activeStreams.set(requestId, controller);
totalTimer = setTimeout(() => controller.abort(), 60_000);
} catch { return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
}
const MAX_RESPONSE_SIZE = delegated ? 131_072 : 10 * 1024 * 1024;
const MAX_REDIRECTS = followRedirects ? 5 : 0;
async function doFetch(fetchUrl, redirectsLeft) {
@@ -437,14 +455,18 @@ function registerProviderHandlers(ctx) {
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
let proxyAgent;
try {
assertDelegation();
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
session: electronModule?.session?.defaultSession,
rejectUnauthorized: skipTLS ? false : undefined,
});
} catch {
} catch (error) {
if (delegated) throw error;
proxyAgent = undefined;
}
assertDelegation();
return new Promise((resolve) => {
const parsedUrl = new URL(fetchUrl);
const isHttps = parsedUrl.protocol === "https:";
@@ -457,6 +479,8 @@ function registerProviderHandlers(ctx) {
};
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
if (proxyAgent) fetchOpts.agent = proxyAgent;
if (controller) fetchOpts.signal = controller.signal;
assertDelegation();
const req = lib.request(parsedUrl, fetchOpts,
(res) => {
// Handle redirects
@@ -505,7 +529,9 @@ function registerProviderHandlers(ctx) {
});
}
return doFetch(resolvedUrl, MAX_REDIRECTS);
try { return await doFetch(resolvedUrl, MAX_REDIRECTS); }
catch (error) { if (!delegated) throw error; return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
finally { if (delegated) { clearTimeout(totalTimer); detach?.(); activeStreams.delete(requestId); } }
});
}
+12 -1
View File
@@ -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,17 @@ 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 = [];
options.maxTurns = 12;
// Global plugin history belongs to core's source namespace. Rehydrate from
// that history each turn instead of creating a second native history store.
options.persistSession = false;
delete options.resume;
}
return options;
}
@@ -333,3 +333,19 @@ 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.equal(options.maxTurns, 12);
assert.equal(options.persistSession, false);
assert.equal(options.resume, undefined);
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);
});
+68 -15
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;
},
@@ -1967,7 +1978,7 @@ function getWorkerJob(jobId, chatSessionId) {
const WORKER_JOB_NOT_FOUND_ERROR = "Background job not found";
async function handleWorkerJobPoll(params = {}) {
async function handleWorkerJobPoll(params = {}, beforeExecute) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
@@ -1979,7 +1990,7 @@ async function handleWorkerJobPoll(params = {}) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, {});
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
// The route is still here but the worker no longer knows the job (pruned or restarted).
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "poll", reason: "worker-job-missing" });
@@ -1990,7 +2001,7 @@ async function handleWorkerJobPoll(params = {}) {
return result;
}
async function handleWorkerJobStop(params = {}) {
async function handleWorkerJobStop(params = {}, beforeExecute) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
@@ -2001,7 +2012,7 @@ async function handleWorkerJobStop(params = {}) {
if (Array.isArray(scopedSessionIds) && job.sessionId && !scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, {});
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
// The route is still here but the worker no longer knows the job (pruned or restarted).
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "stop", reason: "worker-job-missing" });
@@ -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,
@@ -2266,10 +2288,10 @@ async function dispatch(method, params, approvalContext = {}) {
return await handleWorkerJobStart(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobPoll(params);
return await handleWorkerJobPoll(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobStop(params);
return await handleWorkerJobStop(params, approvalContext.beforeExecute);
}
return await handler(params, approvalContext);
} finally {
@@ -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,35 @@ 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);
const beginPluginAgentModelRequest = (id, senderId, tag) => pluginAgentDelegations.beginModelRequest(id, senderId, tag);
function getPluginAgentJobTarget(jobId, chatSessionId) {
if (!pluginAgentDelegations.isReserved(chatSessionId)) return null;
const job = workerBackgroundJobs.get(jobId) ?? backgroundJobs.get(jobId);
return job?.chatSessionId === chatSessionId && typeof job.sessionId === "string" ? job.sessionId : null;
}
module.exports = {
getPluginAgentJobTarget,
beginPluginAgentModelRequest,
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,
+1 -1
View File
@@ -61,7 +61,7 @@ const HARNESS_CAPABILITIES = [
},
{
id: "harness.terminal.read_context",
agentKinds: ["sidebar"],
agentKinds: ["sidebar", "global"],
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
+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 ──
+374
View File
@@ -0,0 +1,374 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { normalizePermissionDeclarations } = require("../permissionResources.cjs");
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
const { DelegatedAgentScopes, isRequestCancellation } = require("./delegatedScope.cjs");
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Plugin Agent operation unavailable");
const FEATURE = require("../generated/plugin-contract.schema.json").$defs.PluginAgentFeature.const;
const CAPABILITIES = Object.freeze(require("../generated/plugin-contract.schema.json").$defs.PluginAgentCapabilities.const);
const TOOLS = listMcpTools().filter((tool) => CAPABILITIES.includes(tool.capabilityId));
const ROUTES = [
["listBackends", "PluginAIEmptyParams", "PluginAgentBackendsResult"],
["selectTargets", "PluginAIEmptyParams", "PluginAgentTargetsResult"],
["listChats", "PluginAIEmptyParams", "PluginAgentChatsResult"],
["openChat", "PluginAgentOpenParams", "PluginAgentChatResult"],
["send", "PluginAgentSendParams", "PluginAgentTurnResult"],
["read", "PluginAgentTurnRef", "PluginAgentEvent"],
["history", "PluginAgentChatRef", "PluginAgentHistoryResult"],
["stop", "PluginAgentTurnRef", "PluginAIBooleanResult"],
["closeChat", "PluginAgentChatRef", "PluginAIBooleanResult"],
];
class PluginAgentService {
constructor(options) {
this.source = new PluginAISourceBinding(options);
this.database = options.database;
this.host = options.hostAdapter;
this.permission = options.permissionEngine;
this.store = options.conversationStore;
this.getSessionBinding = options.getSessionBinding;
this.getMcpBridge = options.getMcpBridge;
this.getTargetManager = options.getTargetManager ?? (() => null);
this.clock = options.clock ?? Date.now;
this.backends = new Map(); this.selections = new Map(); this.chats = new Map(); this.turns = new Map();
this.budgets = new Map(); this.pendingOpens = new Set(); this.registrations = []; this.closed = false;
this.pendingSelections = new Map(); this.targetManager = null; this.targetSubscriptions = [];
this.eventValidator = createDefinitionValidator("PluginAgentEvent");
this.sessionValidator = createDefinitionValidator("PluginAgentHostSession");
this.scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => this.#guard(ctx),
assertHostActive: (ctx) => this.host.assertActive(ctx), clock: this.clock,
authorizeAction: async (ctx, owner, action, signal) => {
const decisionContext = { ...ctx, signal, permissionOperation: Object.freeze({}), sourceOwner: owner.privateOwner };
await this.permission.authorize(decisionContext, {
permission: "ai.agent.actions", resources: action.targets.length
? action.targets.map((target) => `agent-target:${target.kind}:${target.id}:${target.owner}:${target.revision}`) : ["*"],
reason: "Use a delegated Agent tool on selected resources", sessionId: ctx.agentChatId,
displayContext: { modelLabel: ctx.agentBackendLabel, selections: action.targets.map((target) => ({
title: ctx.agentTargetLabels.get(target.id) ?? target.id, scope: "session", version: target.revision,
})), action: { capabilityId: action.capabilityId, text: ctx.agentToolPreview } },
});
} });
this.revocation = this.permission.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.hostInvalidation = this.host.onDidInvalidate(() => this.releaseAll());
}
async #guard(context) {
try {
if (this.closed) throw fail("unavailable");
const owner = await this.source.resolve(context);
await this.host.assertActive(context); this.source.assertCurrent(owner);
return owner;
} catch (error) {
const owner = this.source.bindings.get(context.runtimeId);
if (!isRequestCancellation(error, context) && owner?.pluginId === context.pluginId
&& owner.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
}
#assertNow(turn) {
if (this.closed || turn.done || this.turns.get(turn.handle) !== turn || turn.chat.activeTurn !== turn
|| turn.controller.signal.aborted || turn.chat.expiresAt <= this.clock()
|| turn.ticket?.controller.signal.aborted
|| this.host.generation !== turn.chat.hostGeneration) throw fail("cancelled");
this.source.assertCurrent(turn.owner);
if (this.host.getSenderId() !== turn.senderId) throw fail("cancelled");
if (this.permission.getPolicyRevision(this.database.getActivePlugin(turn.owner.pluginId)) !== turn.policyRevision) throw fail("cancelled");
for (const target of turn.chat.selection.targets) if (!target.isCurrent(target)) throw fail("cancelled");
}
async #chat(params, context) {
const owner = await this.#guard(context); const chat = this.chats.get(params.chatHandle);
if (!chat || chat.owner !== owner || chat.expiresAt <= this.clock() || chat.hostGeneration !== this.host.generation) throw fail("permissionDenied");
return chat;
}
async #turn(params, context) {
const owner = await this.#guard(context); const turn = this.turns.get(params.turnHandle);
if (!turn || turn.owner !== owner || turn.expiresAt <= this.clock()) throw fail("permissionDenied");
return turn;
}
async #backendCurrent(backend, context) {
if (backend.expiresAt <= this.clock() || backend.hostGeneration !== this.host.generation
|| !(await this.host.requestAgent("agent.current", { hostHandle: backend.hostHandle }, context)).current) throw fail("failedPrecondition");
}
#prune() {
for (const map of [this.backends, this.selections]) for (const [key, entry] of map) if (entry.expiresAt <= this.clock()) map.delete(key);
for (const [key, turn] of this.turns) if (turn.expiresAt <= this.clock()) { this.#end(turn); this.turns.delete(key); }
for (const [key, chat] of this.chats) if (chat.expiresAt <= this.clock()) {
this.#forgetChat(chat); this.chats.delete(key);
}
}
#observeTargets() {
const manager = this.getTargetManager();
if (manager === this.targetManager) return;
for (const entry of this.targetSubscriptions) entry.dispose(); this.targetSubscriptions = [];
this.targetManager = manager;
const invalidate = () => {
for (const controller of this.pendingSelections.keys()) controller.abort();
for (const [key, chat] of this.chats) if (chat.selection.targets.some((target) => !target.isCurrent(target))) {
this.#forgetChat(chat); this.chats.delete(key);
}
};
invalidate();
for (const method of ["onSessionOwned", "onSessionClosed", "onWorkerExit"]) {
if (typeof manager?.[method] === "function") this.targetSubscriptions.push(manager[method](invalidate));
}
}
#charge(owner) {
const key = JSON.stringify([owner.sourceKey, owner.privateOwner]); const now = this.clock();
for (const [id, entry] of this.budgets) if (now - entry.start >= 60_000) this.budgets.delete(id);
const budget = this.budgets.get(key) ?? { start: now, requests: 0 };
if (budget.requests >= 6 || (!this.budgets.has(key) && this.budgets.size >= 256)) throw fail("resourceExhausted");
budget.requests++; this.budgets.set(key, budget);
}
registerRpcCapabilities(registry) {
for (const [operation, paramsType, resultType] of ROUTES) {
const input = createDefinitionValidator(paramsType); const output = createDefinitionValidator(resultType);
this.registrations.push(registry.registerRequest(`agent.${operation}`, async (params, context) => {
const result = await this[operation](params, context); await this.#guard(context);
if (!output(result)) throw fail("internal"); return result;
}, { metadata: { capability: "plugin-agent", mutating: !["listBackends", "listChats", "history", "read"].includes(operation), permission: "ai.agent.use" },
validateParams: (params) => { if (!input(params)) throw fail("invalidArgument"); return Object.freeze(structuredClone(params)); },
authorization: async (params, context) => {
await this.#guard(context);
const chat = operation === "send" ? await this.#chat(params, context) : null;
return { permission: "ai.agent.use", resources: ["*"], reason: "Use this plugin's configured global Agent conversation",
...(operation !== "send" ? { deferDecision: true } : {}),
...(chat ? { displayContext: { modelLabel: chat.backend.label,
selections: chat.selection.items.map((item) => ({ title: item.title, scope: "session", version: "selected" })),
action: { capabilityId: "agent.send", text: params.text } } } : {}),
};
},
}));
}
}
async listBackends(_params, context) {
const owner = await this.#guard(context); this.#prune();
const result = await this.host.requestAgent("agent.backends", {}, context); await this.#guard(context);
const items = [];
for (const entry of result.backends) {
let lease = [...this.backends.values()].find((item) => item.owner === owner && item.hostHandle === entry.hostHandle);
if (!lease) {
if (this.backends.size >= 256) throw fail("resourceExhausted");
lease = { ...entry, backendRef: randomUUID(), owner, hostGeneration: this.host.generation, expiresAt: this.clock() + 300_000 };
this.backends.set(lease.backendRef, lease);
}
const eligible = ["catty", "claude"].includes(entry.backend);
items.push({ backendRef: lease.backendRef, backend: entry.backend, label: entry.label,
status: eligible ? entry.status : entry.status === "unavailable" ? "unavailable" : "unsupported",
reason: eligible || entry.status === "unavailable" ? entry.reason : "native-boundary-unverified" });
}
return { items };
}
async selectTargets(_params, context) {
const owner = await this.#guard(context); this.#prune();
this.#observeTargets();
if (this.selections.size >= 64) throw fail("resourceExhausted");
const controller = new AbortController(); this.pendingSelections.set(controller, owner);
let result;
try {
result = await this.host.requestAgent("agent.targets", {}, { ...context,
signal: context.signal ? AbortSignal.any([controller.signal, context.signal]) : controller.signal });
await this.#guard(context); controller.signal.throwIfAborted();
} finally { this.pendingSelections.delete(controller); }
const targets = result.targets.map((item) => {
const binding = this.getSessionBinding(item.sessionId);
if (!binding || !binding.isCurrent(binding)) throw fail("failedPrecondition");
return Object.freeze({ ...binding, kind: "terminal", id: item.sessionId });
});
if (new Set(targets.map((target) => target.id)).size !== targets.length) throw fail("invalidArgument");
const selectionHandle = randomUUID(); const expiresAt = this.clock() + 300_000;
const items = result.targets.map((item) => ({ kind: "terminal", title: item.label }));
this.selections.set(selectionHandle, { owner, targets, terminalSessions: structuredClone(result.targets), items, expiresAt });
return { selectionHandle, expiresAt, items };
}
async listChats(_params, context) {
const owner = await this.#guard(context);
const rows = await this.store.list(owner, () => this.#guard(context));
return { items: rows.map((row) => ({ conversationRef: row.conversationRef, title: row.title, backend: row.agentId, updatedAt: row.updatedAt })) };
}
async openChat(params, context) {
const owner = await this.#guard(context); this.#prune();
const backend = this.backends.get(params.backendRef); const selection = this.selections.get(params.selectionHandle);
if (!backend || backend.owner !== owner || !["catty", "claude"].includes(backend.backend) || backend.status !== "supported"
|| !selection || selection.owner !== owner || selection.expiresAt <= this.clock()) throw fail("permissionDenied");
if (this.chats.size + this.pendingOpens.size >= 64 || [...this.chats.values()].filter((chat) => chat.owner === owner).length >= 8) throw fail("resourceExhausted");
if (params.conversationRef && [...this.pendingOpens].some((item) => item.owner === owner && item.conversationRef === params.conversationRef)) throw fail("alreadyExists");
const opening = { owner, conversationRef: params.conversationRef, active: true }; this.pendingOpens.add(opening); let scope;
try {
await this.#backendCurrent(backend, context);
let record = params.conversationRef ? await this.store.read(owner, params.conversationRef, () => this.#guard(context)) : null;
if (params.conversationRef && !record) throw fail("notFound");
if (record && [...this.chats.values()].some((chat) => chat.owner === owner && chat.record.conversationRef === record.conversationRef)) throw fail("alreadyExists");
const now = this.clock();
record ??= { conversationRef: randomUUID(), id: `plugin-agent:${randomUUID()}`, title: "Global Agent chat", agentId: backend.backend,
scope: { type: "global" }, messages: [], createdAt: now, updatedAt: now };
// Durable conversation identity is conversationRef. A fresh runtime actor
// never reuses revoked privacy/native/tool state when history is reopened.
record.id = `plugin-agent:${randomUUID()}`;
record.agentId = backend.backend; delete record.externalSessionId;
const declared = normalizePermissionDeclarations(context.manifest).has("ai.agent.actions");
const capabilities = declared && this.permission.getMode(context, "ai.agent.actions") !== "deny" ? CAPABILITIES : [];
scope = await this.scopes.issue(context, { backend: { key: backend.backendRef, revision: backend.hostHandle,
isCurrent: () => backend.expiresAt > this.clock() && backend.hostGeneration === this.host.generation }, targets: selection.targets, capabilities });
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
await this.store.save(owner, record, async () => { if (!opening.active) throw fail("cancelled"); await this.#guard(context); });
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
const chatHandle = randomUUID();
this.chats.set(chatHandle, { handle: chatHandle, owner, backend, selection, record, scope, capabilities,
expiresAt: Math.min(scope.expiresAt, selection.expiresAt, backend.expiresAt), hostGeneration: this.host.generation, activeTurn: null, generation: 0 });
return { chatHandle, conversationRef: record.conversationRef, backendLabel: backend.label,
expiresAt: this.chats.get(chatHandle).expiresAt };
} catch (error) { if (scope) this.scopes.discard(scope.handle, owner); throw error; }
finally { this.pendingOpens.delete(opening); }
}
async #dispatchTool(turn, method, params, execute) {
this.#assertNow(turn);
if (++turn.toolCalls > 32) { this.#end(turn, "budget-exceeded"); throw fail("resourceExhausted"); }
const spec = TOOLS.find((item) => item.rpcMethod === method);
if (!spec || !turn.chat.capabilities.includes(spec.capabilityId)) throw fail("permissionDenied");
let ids = [];
if (spec.capabilityId === "session.environment") ids = turn.chat.selection.targets.map((target) => target.id);
else if (["terminal.poll", "terminal.stop"].includes(spec.capabilityId)) {
const id = this.getMcpBridge().getPluginAgentJobTarget(params.jobId, turn.chat.record.id);
if (!id) throw fail("permissionDenied"); ids = [id];
} else { if (typeof params.sessionId !== "string") throw fail("invalidArgument"); ids = [params.sessionId]; }
const targets = ids.map((id) => {
const target = turn.chat.selection.targets.find((item) => item.id === id);
if (!target) throw fail("permissionDenied"); return target;
});
const preview = JSON.stringify(Object.fromEntries(Object.entries(params).filter(([key]) => !["chatSessionId", "scopedSessionIds"].includes(key))), null, 2);
if (Buffer.byteLength(preview) > 16_384) throw fail("resourceExhausted");
const ctx = { ...turn.context, agentChatId: turn.chat.record.id, agentBackendLabel: turn.chat.backend.label,
agentTargetLabels: new Map(turn.chat.selection.terminalSessions.map((item) => [item.sessionId, item.label])), agentToolPreview: preview };
try { await this.#backendCurrent(turn.chat.backend, ctx); }
catch (error) { this.#end(turn, "unavailable"); throw error; }
return this.scopes.dispatch(turn.ticket, ctx, { capabilityId: spec.capabilityId, targets }, async (execution) => {
this.#assertNow(turn); const result = await execute(execution); this.#assertNow(turn); return result;
});
}
async send(params, context) {
const chat = await this.#chat(params, context); const owner = chat.owner;
this.#prune();
if (!params.text.trim()) throw fail("invalidArgument");
if (this.turns.size >= 256 || chat.activeTurn || Buffer.byteLength(params.text) > 16_384 || chat.record.messages.length > 198) throw fail("resourceExhausted");
const controller = new AbortController(); const turn = { handle: randomUUID(), owner, chat, controller, context: { ...context, signal: controller.signal },
done: false, reading: false, bytes: 0, toolCalls: 0, generation: ++chat.generation, expiresAt: this.clock() + 300_000, hostHandle: null,
policyRevision: this.permission.getPolicyRevision(this.database.getActivePlugin(owner.pluginId)), senderId: this.host.getSenderId() };
chat.activeTurn = turn; this.turns.set(turn.handle, turn);
try {
this.#charge(owner); await this.#backendCurrent(chat.backend, context);
turn.ticket = await this.scopes.begin(chat.scope.handle, context);
turn.ticket.controller.signal.addEventListener("abort", () => this.#end(turn), { once: true });
this.#assertNow(turn);
const mcp = this.getMcpBridge();
turn.registration = mcp.registerPluginAgentDelegation(chat.record.id, { turnTag: turn.handle, senderId: turn.senderId,
tools: TOOLS.filter((tool) => chat.capabilities.includes(tool.capabilityId)).map((tool) => tool.mcpTool),
assertCurrent: () => this.#assertNow(turn), dispatch: (method, payload, execute) => this.#dispatchTool(turn, method, payload, execute) });
mcp.setChatSessionCancelled(chat.record.id, false);
turn.timer = setTimeout(() => this.#end(turn, "budget-exceeded"), Math.max(1, Math.min(120_000, chat.expiresAt - this.clock()))); turn.timer.unref?.();
const prior = structuredClone(chat.record);
chat.record.messages.push({ id: randomUUID(), role: "user", content: params.text, timestamp: this.clock() });
chat.record.updatedAt = this.clock();
if (chat.record.title === "Global Agent chat") chat.record.title = params.text.trim().split(/\r?\n/u)[0].slice(0, 80);
prior.title = chat.record.title;
await this.store.save(owner, chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
const result = await this.host.requestAgent("agent.run", { backendHandle: chat.backend.hostHandle, delegationTag: turn.handle,
session: prior, capabilities: chat.capabilities, terminalSessions: chat.selection.terminalSessions, text: params.text, maxIterations: 12 }, turn.context);
this.#assertNow(turn); turn.hostHandle = result.hostHandle;
return { turnHandle: turn.handle, expiresAt: turn.expiresAt };
} catch (error) { this.#end(turn); throw error; }
}
async read(params, context) {
const turn = await this.#turn(params, context);
if (turn.done) return turn.endEvent;
if (turn.reading) throw fail("alreadyExists"); turn.reading = true;
try {
this.#assertNow(turn);
await this.#backendCurrent(turn.chat.backend, context);
const result = await this.host.requestAgent("agent.next", { hostHandle: turn.hostHandle },
{ ...context, signal: AbortSignal.any([context.signal ?? new AbortController().signal, turn.controller.signal]) });
this.#assertNow(turn);
if (result.session) {
if (!this.sessionValidator(result.session) || result.session.id !== turn.chat.record.id
|| result.session.conversationRef !== turn.chat.record.conversationRef || result.session.agentId !== turn.chat.backend.backend) throw fail("internal");
turn.chat.record = structuredClone(result.session);
await this.store.save(turn.owner, turn.chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
}
const event = result.done ? { kind: "end", reason: "completed" } : result.event;
if (!this.eventValidator(event)) throw fail("internal");
turn.bytes += Buffer.byteLength(JSON.stringify(event));
if (turn.bytes > 262_144) throw fail("resourceExhausted");
if (event.kind === "end") this.#end(turn, event.reason);
return event;
} catch (error) { if (turn.done) return turn.endEvent; this.#end(turn, error.code === RPC_ERRORS.resourceExhausted ? "budget-exceeded" : "unavailable"); throw error; }
finally { turn.reading = false; }
}
async history(params, context) {
const chat = await this.#chat(params, context); const items = []; let bytes = 0;
for (const message of [...chat.record.messages].reverse()) {
if (!["user", "assistant"].includes(message?.role) || typeof message.content !== "string") continue;
const text = message.content.slice(0, 32_768); bytes += Buffer.byteLength(text);
if (bytes > 131_072) break; items.unshift({ role: message.role, text });
}
return { items };
}
#end(turn, reason = "cancelled") {
if (turn.done) return;
const wasCurrent = turn.chat.activeTurn === turn;
turn.done = true; turn.endEvent = { kind: "end", reason }; clearTimeout(turn.timer); turn.controller.abort();
if (turn.ticket) this.scopes.finish(turn.ticket);
turn.registration?.dispose();
if (wasCurrent) {
turn.chat.activeTurn = null;
try { this.getMcpBridge().applyChatSessionCancelled(turn.chat.record.id, true); } catch {}
}
if (turn.hostHandle) void this.host.requestAgent("agent.release", { hostHandle: turn.hostHandle },
{ ...turn.context, signal: undefined }).catch(() => {});
}
async stop(params, context) { const turn = await this.#turn(params, context); this.#end(turn); return { ok: true }; }
async closeChat(params, context) {
const chat = await this.#chat(params, context);
this.#forgetChat(chat); this.chats.delete(chat.handle); return { ok: true };
}
#forgetChat(chat) {
if (chat.activeTurn) this.#end(chat.activeTurn);
this.scopes.discard(chat.scope.handle, chat.owner);
try { this.host.releaseAgentChat?.(chat.record.id, chat.owner.runtimeId); } catch {}
try { void this.getMcpBridge().cleanupScopedMetadata?.(chat.record.id)?.catch(() => {}); } catch {}
}
releaseRuntime(runtimeId) {
for (const [controller, owner] of this.pendingSelections) if (owner.runtimeId === runtimeId) controller.abort();
for (const turn of this.turns.values()) if (turn.owner.runtimeId === runtimeId) this.#end(turn);
for (const chat of this.chats.values()) if (chat.owner.runtimeId === runtimeId) this.#forgetChat(chat);
for (const opening of this.pendingOpens) if (opening.owner.runtimeId === runtimeId) opening.active = false;
for (const map of [this.backends, this.selections, this.chats, this.turns]) for (const [id, entry] of map) if (entry.owner.runtimeId === runtimeId) map.delete(id);
this.scopes.releaseRuntime(runtimeId); this.source.releaseRuntime(runtimeId); this.host.releaseRuntime(runtimeId);
}
releasePlugin(pluginId) {
const ids = new Set([...this.source.bindings.values()].filter((owner) => owner.pluginId === pluginId).map((owner) => owner.runtimeId));
for (const id of ids) this.releaseRuntime(id);
}
releaseAll() { for (const id of [...this.source.bindings.keys()]) this.releaseRuntime(id); }
shutdown() {
this.releaseAll(); this.closed = true; this.scopes.shutdown(); this.source.shutdown(); this.revocation.dispose(); this.hostInvalidation();
for (const item of this.registrations) item.dispose(); this.host.shutdown();
for (const item of this.targetSubscriptions) item.dispose(); this.targetSubscriptions = [];
}
}
module.exports = { PluginAgentService, PLUGIN_AGENT_FEATURE: FEATURE, PLUGIN_AGENT_CAPABILITIES: CAPABILITIES };
@@ -0,0 +1,171 @@
"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 { randomUUID } = require("node:crypto");
const { PluginAgentService } = require("./agentService.cjs");
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
const { PluginPermissionEngine } = require("../permissionEngine.cjs");
const { PluginHostRpcRegistry } = require("../hostRpcRegistry.cjs");
function deferred() { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-service-"));
const manifest = { id: "com.example.agent", publisher: "example", version: "1.0.0", permissions: { required: ["ai.agent.use", "ai.agent.actions"] } };
let identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "r1", runtimeKind: "browser", manifest };
let sourceId = "source-a"; let time = 1; let locked = false; const modes = new Map(); let focus = "a"; let effects = 0;
const audits = []; const decisions = []; const hostRequests = []; const invalidations = new Set();
const plugin = () => ({ id: manifest.id, enabled: true, activeVersion: manifest.version, manifest,
source: { id: sourceId, kind: "third-party", origin: "https://synthetic.invalid" }, runtime: { status: "running" } });
const database = { getActivePlugin: (id) => id === manifest.id ? plugin() : null,
getRetainedPluginSource: () => plugin().source, privateOwner: () => `opaque-owner:${sourceId}`,
publicPluginId: (owner) => owner === `opaque-owner:${sourceId}` ? manifest.id : null,
getPermissionMode: (_id, permission) => modes.get(permission) ?? "allow",
listPermissionGrants: () => [], recordSecurityAudit: (_id, event, value) => audits.push({ event, ...value }) };
let approve = async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" });
const permission = new PluginPermissionEngine({ database, requestDecision: (request) => { decisions.push(request); return approve(request); } });
const targetState = new Map([["a", { owner: "7", revision: "worker-a:1" }], ["b", { owner: "8", revision: "worker-a:2" }]]);
const backendHandle = randomUUID(); const externalHandle = randomUUID();
const sessions = new Map(); const events = [];
let run = async (payload) => { const handle = randomUUID(); sessions.set(handle, structuredClone(payload.session)); return { hostHandle: handle }; };
let current = true;
let choose = async () => ({ targets: [...targetState.keys()].map((id) => ({ sessionId: id, hostname: `${id}.invalid`, label: `Synthetic ${id}`, connected: true })) });
const resourceListeners = new Set();
const watch = (callback) => { resourceListeners.add(callback); return { dispose: () => resourceListeners.delete(callback) }; };
const targetManager = { onSessionOwned: watch, onSessionClosed: watch, onWorkerExit: watch };
const host = { generation: 1, getSenderId: () => 7,
assertActive: async (context) => { await context.assertActive(); if (locked) throw Error("Locked"); },
onDidInvalidate: (callback) => { invalidations.add(callback); return () => invalidations.delete(callback); },
requestAgent: async (kind, payload) => {
hostRequests.push({ kind, payload: structuredClone(payload) });
if (kind === "agent.backends") return { backends: [
{ hostHandle: backendHandle, backend: "catty", label: "Catty fixture", status: "supported", reason: "ready" },
{ hostHandle: externalHandle, backend: "claude", label: "Claude fixture", status: "supported", reason: "ready" },
{ hostHandle: randomUUID(), backend: "codex", label: "Codex fixture", status: "supported", reason: "ready" },
] };
if (kind === "agent.current") return { current };
if (kind === "agent.targets") return choose();
if (kind === "agent.run") return run(payload);
if (kind === "agent.next") return { done: false, event: events.shift() ?? { kind: "end", reason: "completed" }, session: sessions.get(payload.hostHandle) };
return { ok: true };
}, releaseRuntime: () => {}, shutdown: () => {} };
const delegation = new PluginAgentMcpDelegations();
const mcp = { registerPluginAgentDelegation: (...args) => delegation.register(...args),
setChatSessionCancelled: () => {}, applyChatSessionCancelled: () => {}, getPluginAgentJobTarget: () => null };
const service = new PluginAgentService({ database, permissionEngine: permission, hostAdapter: host,
conversationStore: new PluginAgentConversationStore(directory), getRuntimeIdentity: () => identity, getMcpBridge: () => mcp, clock: () => time,
getTargetManager: () => targetManager,
getSessionBinding: (id) => { const snapshot = targetState.get(id); return snapshot && { ...snapshot,
isCurrent: (target) => targetState.get(id)?.owner === target.owner && targetState.get(id)?.revision === target.revision }; } });
const registry = new PluginHostRpcRegistry(); registry.use(permission.createMiddleware()); service.registerRpcCapabilities(registry);
const call = (method, params = {}, overrides = {}) => registry.createRoutes(identity).requestHandlers[`agent.${method}`](params, { signal: new AbortController().signal, ...overrides });
const open = async (backend = "catty", conversationRef) => {
const backends = await call("listBackends"); const selection = await call("selectTargets");
return call("openChat", { backendRef: backends.items.find((item) => item.backend === backend).backendRef, selectionHandle: selection.selectionHandle, ...(conversationRef ? { conversationRef } : {}) });
};
const dispatch = async (turn, id = "a") => {
const internal = service.turns.get(turn.turnHandle); if (!internal) throw Error("Turn unavailable");
return delegation.dispatch("netcatty/exec", { chatSessionId: internal.chat.record.id, sessionId: id, command: "printf synthetic" },
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, (context) => { context.beforeExecute(); effects++; return "synthetic"; });
};
t.after(() => { service.shutdown(); delegation.shutdown(); permission.shutdown(); fs.rmSync(directory, { recursive: true, force: true }); });
return { service, call, open, dispatch, events, sessions, hostRequests, decisions, audits, host, delegation, targetState,
effects: () => effects, focusTo: (id) => { focus = id; }, focus: () => focus, modeTo: (value, permission = "ai.agent.actions") => { modes.set(permission, value); },
approve: (fn) => { approve = fn; }, run: (fn) => { run = fn; }, tick: (ms) => { time += ms; },
choose: (fn) => { choose = fn; }, resourceChanged: () => { for (const callback of resourceListeners) callback(); },
replaceSource: () => { sourceId = "source-b"; }, currentTo: (value) => { current = value; },
restart: () => { service.releaseRuntime(identity.runtimeId); identity = { ...identity, runtimeId: randomUUID() }; },
lock: () => { locked = true; host.generation++; for (const callback of invalidations) callback(); } };
}
test("registered Agent routes validate opaque authority, report unsupported modes and preserve core-only native history", async (t) => {
const f = fixture(t); const backends = await f.call("listBackends");
assert.equal(backends.items.find((entry) => entry.backend === "codex").status, "unsupported");
await assert.rejects(f.call("openChat", { backendRef: randomUUID(), selectionHandle: randomUUID(), permissionMode: "auto" }));
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic conversation" });
const [handle, session] = [...f.sessions][0];
session.messages = [{ role: "user", content: "Synthetic conversation" }, { role: "assistant", content: "Synthetic answer" }];
session.externalSessionId = "NATIVE_PRIVATE_SESSION"; f.events.push({ kind: "text", text: "Synthetic answer" });
assert.deepEqual(await f.call("read", { turnHandle: turn.turnHandle }), { kind: "text", text: "Synthetic answer" });
await f.call("read", { turnHandle: turn.turnHandle });
assert.deepEqual(await f.call("history", { chatHandle: chat.chatHandle }), { items: [
{ role: "user", text: "Synthetic conversation" }, { role: "assistant", text: "Synthetic answer" } ] });
const summaries = await f.call("listChats"); assert.doesNotMatch(JSON.stringify({ chat, turn, summaries }), /NATIVE_|opaque-owner|source-a|plugin-agent:/);
await f.call("closeChat", { chatHandle: chat.chatHandle });
const reopened = await f.open("claude", chat.conversationRef);
assert.equal(f.service.chats.get(reopened.chatHandle).record.externalSessionId, undefined);
assert.equal((await f.call("history", { chatHandle: reopened.chatHandle })).items.length, 2);
assert.ok(handle); f.restart(); f.replaceSource();
assert.deepEqual(await f.call("listChats"), { items: [] });
await assert.rejects(f.open("catty", chat.conversationRef));
});
test("actual source policy decides once per tool and targets never follow focus across workspaces", async (t) => {
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic action" }); f.focusTo("unselected");
assert.equal(await f.dispatch(turn, "b"), "synthetic"); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
assert.equal(f.decisions[0].permission, "ai.agent.actions"); assert.equal(f.decisions[0].displayContext.action.capabilityId, "terminal.execute");
await assert.rejects(f.dispatch(turn, f.focus())); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
f.approve(async (request) => ({ requestId: request.requestId, decision: "deny" }));
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 2);
const entered = deferred(); const resume = deferred();
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
const pending = f.dispatch(turn); const rejection = assert.rejects(pending); await entered.promise;
f.targetState.set("a", { owner: "new-window", revision: "new-generation" }); resume.resolve(); await rejection;
assert.equal(f.effects(), 1);
});
test("Actions Deny keeps model conversation available with no tools; Agent-use Deny prevents host dispatch", async (t) => {
const f = fixture(t); f.modeTo("deny"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic model-only intent" });
const internal = f.service.turns.get(turn.turnHandle);
assert.deepEqual(f.delegation.configuration(internal.chat.record.id, turn.turnHandle).tools, []);
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 0); assert.equal(f.decisions.length, 0);
await f.call("stop", { turnHandle: turn.turnHandle });
f.modeTo("deny", "ai.agent.use"); const count = f.hostRequests.length;
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Denied" })); assert.equal(f.hostRequests.length, count);
});
test("source replacement, lock and backend changes revoke native work and reject late events", async (t) => {
for (const kind of ["source", "lock", "backend"]) {
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
if (kind === "source") f.replaceSource(); else if (kind === "lock") f.lock(); else f.currentTo(false);
await assert.rejects(f.dispatch(turn)); assert.equal(native.signal.aborted, true); assert.equal(f.effects(), 0);
if (kind !== "backend") await assert.rejects(f.call("read", { turnHandle: turn.turnHandle }));
}
});
test("per-source turn budget survives runtime restart and is isolated from a different source", async (t) => {
const f = fixture(t); let chat = await f.open();
for (let i = 0; i < 6; i++) { const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" }); await f.call("stop", { turnHandle: turn.turnHandle }); }
f.restart(); chat = await f.open(); await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Over budget" }));
f.restart(); f.replaceSource(); chat = await f.open();
assert.ok((await f.call("send", { chatHandle: chat.chatHandle, text: "Isolated source" })).turnHandle);
});
test("late start/open completion after release cannot publish handles or affect a replacement turn", async (t) => {
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
const starting = f.call("send", { chatHandle: chat.chatHandle, text: "Delayed synthetic start" }); const rejected = assert.rejects(starting);
await entered.promise; f.restart(); resume.resolve(); await rejected; assert.equal(f.service.chats.size, 0); assert.equal(f.service.turns.size, 0);
const next = await f.open(); f.tick(300_001);
await assert.rejects(f.call("send", { chatHandle: next.chatHandle, text: "Expired" }));
assert.ok((await f.open()).chatHandle); assert.equal(f.service.chats.size, 1);
});
test("worker lifecycle during target confirmation cannot silently bind a replacement connection or leave native work running", async (t) => {
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending work" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
const entered = deferred(); const resume = deferred();
f.choose(async () => { entered.resolve(); await resume.promise; return { targets: [{ sessionId: "a", hostname: "a.invalid", label: "Synthetic a", connected: true }] }; });
const pending = f.call("selectTargets"); const rejected = assert.rejects(pending); await entered.promise;
f.targetState.set("a", { owner: "replacement-window", revision: "replacement-generation" }); f.resourceChanged();
assert.equal(native.signal.aborted, true); assert.equal(f.service.chats.size, 0);
const count = f.service.selections.size; resume.resolve(); await rejected; assert.equal(f.service.selections.size, count);
});
+10 -1
View File
@@ -163,6 +163,15 @@ class DelegatedAgentScopes {
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
}
// Main-owned cleanup after a pending operation loses its lease. Exact owner
// object matching prevents old cleanup from deleting a replacement source.
discard(handle, owner) {
const scope = this.scopes.get(handle);
if (!scope || scope.owner !== owner) return false;
this.#revoke(scope);
return true;
}
releasePlugin(pluginId) {
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
}
@@ -177,4 +186,4 @@ class DelegatedAgentScopes {
}
}
module.exports = { DelegatedAgentScopes };
module.exports = { DelegatedAgentScopes, isRequestCancellation };
+113
View File
@@ -0,0 +1,113 @@
"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, modelRequests: 0, 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);
}
beginModelRequest(chatSessionId, senderId, turnTag) {
this.assertCaller(chatSessionId, senderId, turnTag);
const entry = this.sessions.get(chatSessionId);
if (++entry.modelRequests > 32) { this.release(chatSessionId); throw Error("Agent model request budget exceeded"); }
}
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,71 @@
"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();
});
test("model request limits cannot be reset or consumed by a different turn or caller", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
registry.register(id, options(tag)); let cancelled = 0; registry.onRelease(id, tag, () => cancelled++);
for (let i = 0; i < 32; i++) {
assert.throws(() => registry.beginModelRequest(id, 8, tag));
assert.throws(() => registry.beginModelRequest(id, 7, randomUUID()));
registry.beginModelRequest(id, 7, tag);
}
assert.throws(() => registry.beginModelRequest(id, 7, tag), /budget/); assert.equal(cancelled, 1); assert.equal(registry.has(id), false);
});
+9
View File
@@ -51,6 +51,15 @@ class PluginAISourceBinding {
return binding;
}
// Main-owned final dispatch guard. Only a previously authenticated binding
// object can reach this path; it is never reconstructed from plugin fields.
assertCurrent(binding) {
if (!binding || this.bindings.get(binding.runtimeId) !== binding) throw denied();
const current = this.#snapshot(binding);
if (current.sourceKey !== binding.sourceKey || current.privateOwner !== binding.privateOwner
|| current.pluginId !== binding.pluginId || current.pluginVersion !== binding.pluginVersion) throw denied();
}
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
releasePlugin(pluginId) {
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
@@ -39,3 +39,12 @@ test("source/owner changes during asynchronous authentication cannot commit a bi
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
});
test("final dispatch checks require the exact authenticated binding and current host provenance", async () => {
const f = fixture(); const binding = await f.binding.resolve(f.context);
assert.doesNotThrow(() => f.binding.assertCurrent(binding));
assert.throws(() => f.binding.assertCurrent({ ...binding }));
f.ownerTo("changed-owner"); assert.throws(() => f.binding.assertCurrent(binding));
f.ownerTo("source:opaque-a:com.example.assistant");
f.binding.releaseRuntime("r1"); assert.throws(() => f.binding.assertCurrent(binding));
});
+27 -9
View File
@@ -11,6 +11,12 @@ const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", re
// supplied by the shared contract task; no provider config or key crosses it.
class PluginAITrustedHostAdapter {
constructor(options) {
// Separate trusted Agent transport instances reuse the same sender/nonce
// guards. These construction options never come from a plugin request.
this.channels = options.channels ?? CHANNELS;
this.requestDefinition = options.requestDefinition ?? "PluginAIHostRequest";
this.responseDefinition = options.responseDefinition ?? "PluginAIHostResponse";
this.maxEnvelopeBytes = Math.min(options.maxEnvelopeBytes ?? 24_576, 1_048_576);
this.getWindow = options.getWindow;
this.isLocked = options.isLocked ?? (() => true);
this.dialog = options.dialog;
@@ -25,11 +31,11 @@ class PluginAITrustedHostAdapter {
registerIpc(ipcMain) {
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
this.validateRequest ??= createDefinitionValidator(this.requestDefinition);
this.validateResponse ??= createDefinitionValidator(this.responseDefinition);
ipcMain.handle(this.channels.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(this.channels.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(this.channels.ready); ipcMain.removeHandler(this.channels.response); } };
return this.ipcRegistration;
}
@@ -68,7 +74,7 @@ class PluginAITrustedHostAdapter {
#envelope(kind, payload, runtimeId) {
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
assertPluginJsonValue(request, { maxBytes: 24_576 });
assertPluginJsonValue(request, { maxBytes: this.maxEnvelopeBytes });
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
return request;
}
@@ -80,7 +86,7 @@ class PluginAITrustedHostAdapter {
#cleanup(kind, payload, runtimeId, sender = this.owner) {
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
try { sender.send(this.channels.request, this.#envelope(kind, payload, runtimeId)); } catch {}
}
async #request(kind, payload, context) {
@@ -102,14 +108,14 @@ class PluginAITrustedHostAdapter {
this.pending.set(request.requestId, entry);
context.signal?.addEventListener("abort", entry.abort, { once: true });
if (context.signal?.aborted) { entry.abort(); return; }
try { sender.send(CHANNELS.request, request); }
try { sender.send(this.channels.request, request); }
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
});
}
async respond(sender, response) {
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
assertPluginJsonValue(response, { maxBytes: 24_576 });
assertPluginJsonValue(response, { maxBytes: this.maxEnvelopeBytes });
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
@@ -126,6 +132,18 @@ class PluginAITrustedHostAdapter {
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
}
// Host-only, canonical typed request path for a separate Agent instance.
requestAgent(kind, payload, context) {
if (this.requestDefinition !== "PluginAgentHostRequest" || !kind.startsWith("agent.")) throw fail("unsupported", "Agent host route unavailable");
return this.#request(kind, payload, context);
}
getSenderId() { return this.#owner().id; }
releaseAgentChat(chatSessionId, runtimeId) {
if (this.requestDefinition !== "PluginAgentHostRequest" || !/^plugin-agent:[0-9a-f-]{36}$/u.test(chatSessionId)) throw fail("invalidArgument", "Agent conversation unavailable");
this.#cleanup("agent.close", { chatSessionId }, runtimeId);
}
async selectModel(context) {
const selected = await this.#request("model.select", {}, context);
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
+24 -1
View File
@@ -188,6 +188,7 @@ class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedReadDirectories = options.protectedReadDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
@@ -235,8 +236,10 @@ class PluginFilesystemBroker {
async readFile(params, context) {
const value = assertReadParams(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file");
if (stats.size > value.maxBytes) {
@@ -253,7 +256,9 @@ class PluginFilesystemBroker {
if (openedStats.size > value.maxBytes) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
}
assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical));
const current = await this.fileSystem.realpath(canonical);
await this.#assertReadable(current);
assertAuthorizedPath(context, "filesystem.read", current);
const bytes = await readBoundedFileHandle(handle, value.maxBytes);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength);
await context.assertActive();
@@ -284,6 +289,20 @@ class PluginFilesystemBroker {
}
}
async #assertReadable(candidate) {
for (const directory of this.protectedReadDirectories) {
const variants = [path.resolve(directory)];
try { variants.push(await this.fileSystem.realpath(directory)); }
catch (error) { if (error?.code !== "ENOENT") throw error; }
for (const parent of variants) {
const relative = path.relative(parent, candidate);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Private host conversation storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
@@ -317,8 +336,10 @@ class PluginFilesystemBroker {
async stat(params, context) {
const value = assertPathParams(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
await context.assertActive();
return {
@@ -330,8 +351,10 @@ class PluginFilesystemBroker {
async readDirectory(params, context) {
const value = assertPathParams(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
if (!this.openDirectoryHandle) {
@@ -93,6 +93,24 @@ test("filesystem broker uses canonical authorization for bounded read, write, st
]);
});
test("host conversation roots are unreadable through explicit filesystem grants, including symlink aliases", async (context) => {
const root = createRoot(context); const privateRoot = path.join(root, "agent-chats");
await fsp.mkdir(privateRoot); const file = path.join(privateRoot, "synthetic.json"); await fsp.writeFile(file, "PRIVATE_OTHER_SOURCE_HISTORY");
const broker = new PluginFilesystemBroker({ protectedReadDirectories: [privateRoot], openDirectoryHandle: openTestDirectoryHandle });
const authorize = (candidate, kind = "exact") => runtimeContext(broker.describeReadAuthorization({ path: candidate }, kind));
await assert.rejects(broker.readFile({ path: file }, authorize(file)), (error) => error.code === RPC_ERRORS.permissionDenied);
await assert.rejects(broker.stat({ path: privateRoot }, authorize(privateRoot)), (error) => error.code === RPC_ERRORS.permissionDenied);
await assert.rejects(broker.readDirectory({ path: privateRoot }, authorize(privateRoot, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
if (process.platform !== "win32") {
const alias = path.join(root, "alias.json"); await fsp.symlink(file, alias);
await assert.rejects(broker.readFile({ path: alias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
const directoryAlias = path.join(root, "directory-alias"); await fsp.symlink(privateRoot, directoryAlias);
await assert.rejects(broker.readDirectory({ path: directoryAlias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
}
const ordinary = path.join(root, "ordinary.txt"); await fsp.writeFile(ordinary, "Synthetic public file");
assert.deepEqual(await broker.readFile({ path: ordinary }, authorize(ordinary)), { data: "Synthetic public file" });
});
test("filesystem authorization removes trailing directory separators before permission", async (context) => {
const root = createRoot(context);
const requestedPath = `${root}${path.sep}`;
@@ -5789,6 +5789,9 @@
"type": "object",
"additionalProperties": false,
"properties": {
"action": {
"$ref": "#/$defs/PluginAgentActionPreview"
},
"modelLabel": {
"type": "string",
"minLength": 1,
@@ -5796,7 +5799,7 @@
},
"selections": {
"type": "array",
"maxItems": 5,
"maxItems": 32,
"items": {
"type": "object",
"required": [
@@ -7586,6 +7589,791 @@
"additionalProperties": false
}
]
},
"PluginAgentHostBackend": {
"type": "object",
"required": [
"hostHandle",
"label",
"backend",
"status",
"reason"
],
"properties": {
"hostHandle": {
"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
},
"PluginAgentHostTerminal": {
"type": "object",
"required": [
"sessionId",
"hostname",
"label",
"connected"
],
"properties": {
"sessionId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"hostId": {
"type": "string",
"maxLength": 128
},
"hostname": {
"type": "string",
"maxLength": 256
},
"label": {
"type": "string",
"maxLength": 160
},
"protocol": {
"type": "string",
"maxLength": 32
},
"username": {
"type": "string",
"maxLength": 256
},
"connected": {
"type": "boolean"
}
},
"additionalProperties": false
},
"PluginAgentHostSession": {
"type": "object",
"required": [
"conversationRef",
"id",
"title",
"agentId",
"scope",
"messages",
"createdAt",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"maxLength": 128,
"pattern": "^plugin-agent:[0-9a-f-]+$"
},
"title": {
"type": "string",
"maxLength": 160
},
"agentId": {
"type": "string",
"maxLength": 128
},
"scope": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"const": "global"
}
},
"additionalProperties": false
},
"messages": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/JsonValue"
}
},
"createdAt": {
"type": "integer",
"minimum": 0
},
"updatedAt": {
"type": "integer",
"minimum": 0
},
"externalSessionId": {
"type": "string",
"maxLength": 4096
},
"contextCompaction": {
"$ref": "#/$defs/JsonValue"
}
},
"additionalProperties": false
},
"PluginAgentHostRequest": {
"description": "Private main-to-trusted-app Agent envelopes. Never exposed in plugin views. Configuration and credentials remain in the host.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.backends"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.targets"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.current"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.run"
},
"payload": {
"type": "object",
"required": [
"backendHandle",
"delegationTag",
"session",
"terminalSessions",
"text",
"capabilities",
"maxIterations"
],
"properties": {
"backendHandle": {
"type": "string",
"format": "uuid"
},
"delegationTag": {
"type": "string",
"format": "uuid"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
},
"terminalSessions": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostTerminal"
}
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384
},
"capabilities": {
"type": "array",
"maxItems": 6,
"uniqueItems": true,
"items": {
"enum": [
"session.environment",
"terminal.execute",
"terminal.start",
"terminal.poll",
"terminal.stop",
"harness.terminal.read_context"
]
}
},
"maxIterations": {
"type": "integer",
"minimum": 1,
"maximum": 12
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.next"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.stop"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.release"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "request.cancel"
},
"payload": {
"type": "object",
"required": [
"requestId"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "runtime.release"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.close"
},
"payload": {
"type": "object",
"required": [
"chatSessionId"
],
"properties": {
"chatSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^plugin-agent:[0-9a-f-]+$"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PluginAgentHostResult": {
"oneOf": [
{
"type": "object",
"required": [
"backends"
],
"properties": {
"backends": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostBackend"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"targets"
],
"properties": {
"targets": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostTerminal"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"current"
],
"properties": {
"current": {
"type": "boolean"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"event"
],
"properties": {
"done": {
"const": false
},
"event": {
"$ref": "#/$defs/PluginAgentEvent"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"session"
],
"properties": {
"done": {
"const": true
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
}
]
},
"PluginAgentHostResponse": {
"description": "Private one-use trusted application Agent response. Core history may include native resume identity; public plugin responses must project it out.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"result"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"result": {
"$ref": "#/$defs/PluginAgentHostResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"error"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"error": {
"const": true
}
},
"additionalProperties": false
}
]
},
"PluginAgentCapabilities": {
"const": [
"session.environment",
"terminal.execute",
"terminal.start",
"terminal.poll",
"terminal.stop",
"harness.terminal.read_context"
]
},
"PluginAgentActionPreview": {
"type": "object",
"required": [
"capabilityId",
"text"
],
"additionalProperties": false,
"properties": {
"capabilityId": {
"type": "string",
"maxLength": 160
},
"text": {
"type": "string",
"maxLength": 98304
}
}
}
}
}
+2
View File
@@ -2,3 +2,5 @@
require("./agent/delegatedScope.test.cjs");
require("./agent/conversationStore.test.cjs");
require("./agent/contract.test.cjs");
require("./agent/mcpDelegation.test.cjs");
require("./agent/agentService.test.cjs");
+43 -2
View File
@@ -32,6 +32,8 @@ const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
const { PluginAgentService, PLUGIN_AGENT_FEATURE } = require("./agent/agentService.cjs");
const { PluginAgentConversationStore } = require("./agent/conversationStore.cjs");
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginHostVaultService } = require("./hostVaultService.cjs");
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
@@ -99,12 +101,14 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, PLUGIN_AGENT_FEATURE, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
let aiContextService;
let aiHostAdapter;
let agentService;
let agentHostAdapter;
let permissionDecisionHost;
let manager;
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
@@ -162,7 +166,8 @@ function createPluginHostService(options) {
const filesystemBroker = new PluginFilesystemBroker({
quotaManager,
openDirectoryHandle: options.openPluginDirectoryHandle,
protectedDirectories: [paths.logs],
protectedDirectories: [paths.logs, path.join(paths.root, "agent-chats")],
protectedReadDirectories: [path.join(paths.root, "agent-chats")],
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
});
const networkBroker = new PluginNetworkBroker({
@@ -294,6 +299,7 @@ function createPluginHostService(options) {
runtimeCleanup: async (identity) => {
byteStreamService?.releaseRuntime(identity.runtimeId);
aiContextService?.releaseRuntime(identity.runtimeId);
agentService?.releaseRuntime(identity.runtimeId);
interactionService?.releaseRuntime(identity.runtimeId);
leaseStore.revokeRuntime(identity.runtimeId);
await companionSupervisor.releaseRuntime(identity.runtimeId);
@@ -314,9 +320,40 @@ function createPluginHostService(options) {
hostAdapter: aiHostAdapter,
});
aiContextService.registerRpcCapabilities(rpcRegistry);
agentHostAdapter = options.pluginAgentHostAdapter ?? new PluginAITrustedHostAdapter({
getWindow: options.getInteractionWindow ?? (() => null),
isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock,
channels: { request: "netcatty:plugins:agent-host-request", ready: "netcatty:plugins:agent-host-ready", response: "netcatty:plugins:agent-host-response" },
requestDefinition: "PluginAgentHostRequest", responseDefinition: "PluginAgentHostResponse", maxEnvelopeBytes: 1_048_576,
// Agent native/model streams have their own exact-turn main lease.
cancelProviderStreams: () => {},
});
if (options.electron.ipcMain) agentHostAdapter.registerIpc?.(options.electron.ipcMain);
agentService = new PluginAgentService({ database, permissionEngine,
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
hostAdapter: agentHostAdapter,
conversationStore: new PluginAgentConversationStore(path.join(paths.root, "agent-chats")),
getMcpBridge: () => require("../bridges/mcpServerBridge.cjs"),
getTargetManager: options.getTerminalWorkerManager,
getSessionBinding: (sessionId) => {
const worker = options.getTerminalWorkerManager?.();
const ownerId = worker?.getSessionOwnerWebContentsId?.(sessionId);
const binding = worker?.getPluginSessionBinding?.(sessionId, ownerId);
if (!Number.isSafeInteger(ownerId) || !binding) return null;
const revision = JSON.stringify([binding.epoch, binding.generation]);
return Object.freeze({ owner: String(ownerId), revision, isCurrent: (snapshot) => {
if (options.getTerminalWorkerManager?.() !== worker || worker.getSessionOwnerWebContentsId(sessionId) !== ownerId) return false;
const current = worker.getPluginSessionBinding(sessionId, ownerId);
return snapshot.owner === String(ownerId) && snapshot.revision === revision && current != null
&& JSON.stringify([current.epoch, current.generation]) === revision;
} });
},
});
agentService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
contributionService.onPluginDisabled = (pluginId) => {
aiContextService.releasePlugin(pluginId);
agentService.releasePlugin(pluginId);
return originalOnPluginDisabled(pluginId);
};
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
@@ -459,6 +496,7 @@ function createPluginHostService(options) {
interactionService.shutdown();
byteStreamService.shutdown();
aiContextService.shutdown();
agentService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
@@ -497,6 +535,7 @@ function createPluginHostService(options) {
networkBroker,
byteStreamService,
aiContextService,
agentService,
permissionEngine,
permissionDecisionHost,
quotaManager,
@@ -512,6 +551,8 @@ function createPluginHostService(options) {
try { interactionService?.shutdown(); } catch {}
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
try { agentService?.shutdown(); } catch {}
if (!agentService) { try { agentHostAdapter?.shutdown?.(); } catch {} }
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
void logService.close();
database.close();
@@ -78,6 +78,10 @@ class PluginPermissionDecisionHost {
resources: (request.resources ?? []).map((resource) => escapePermissionText(resource)),
...(request.displayContext ? { displayContext: {
...(request.displayContext.modelLabel ? { modelLabel: escapePermissionText(request.displayContext.modelLabel) } : {}),
...(request.displayContext.action ? { action: {
capabilityId: escapePermissionText(request.displayContext.action.capabilityId, 160),
text: escapePermissionText(request.displayContext.action.text, 16_384),
} } : {}),
selections: (request.displayContext.selections ?? []).map((item) => ({
scope: item.scope, title: escapePermissionText(item.title), version: escapePermissionText(item.version),
})),
+4
View File
@@ -401,6 +401,9 @@ function createPluginContext(config, client, runtimeApi) {
list: (params) => client.request("context.selected.list", params),
read: (params) => client.request("context.selected.read", params),
};
const agent = Object.fromEntries(["listBackends", "selectTargets", "listChats", "openChat", "send", "read", "history", "stop", "closeChat"].map((operation) => [
operation, (params) => client.request(`agent.${operation}`, params, operation === "read" ? { deadlineMs: 65_000 } : {}),
]));
const filesystem = {
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
path: filePath,
@@ -583,6 +586,7 @@ function createPluginContext(config, client, runtimeApi) {
credentials,
network,
ai,
agent,
selectedContext,
ui,
vault,
+19 -12
View File
@@ -109,6 +109,13 @@ function createPreloadApi(ctx) {
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
setPluginAgentHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:agent-host-ready", ready),
respondPluginAgentHost: (response) => ipcRenderer.invoke("netcatty:plugins:agent-host-response", response),
onPluginAgentHostRequest: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:agent-host-request", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:agent-host-request", listener);
},
respondPluginAIHost: (response) => ipcRenderer.invoke("netcatty:plugins:ai-host-response", response),
onPluginAIHostRequest: (callback) => {
const listener = (_event, payload) => callback(payload);
@@ -1987,23 +1994,23 @@ function createPreloadApi(ctx) {
aiChatCancel: async (requestId) => {
return ipcRenderer.invoke("netcatty:ai:chat:cancel", { requestId });
},
aiFetch: async (url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify) => {
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify });
aiFetch: async (url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify, requestId) => {
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify, ...(requestId ? { requestId } : {}) });
},
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 +2071,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 });
+3
View File
@@ -0,0 +1,3 @@
.package/
dist/
*.ncpkg
+20
View File
@@ -0,0 +1,20 @@
# Global Agent Chat example (work in progress)
This browser sandbox plugin provides a custom Chat view using the existing
host-owned Agent runtime. Choose a configured Catty or Claude Code backend,
explicitly choose connected terminal targets, open a new or owned conversation,
then Send. Stop cancels the exact turn. Refresh and reselection are explicit;
expired authority never silently re-shares targets or retries inference.
Core stores source-isolated conversation history. The plugin receives text,
usage and safe activity summaries, never native session IDs, credentials, raw
tool arguments/results or executable/MCP configuration. Tool actions use the
plugin's `ai.agent.actions` Allow / Ask / Deny setting. Other backends report
unsupported/unavailable until their native tool boundaries are verified.
Build with `node examples/plugins/global-agent/build.mjs`, then package the
`.package` directory using `@netcatty/plugin-cli`. Install the resulting `.ncpkg`
through Settings → Plugins. This is a minimal example, not a background service.
Real provider/Claude usage requires a user's already configured backend and
separate approval for paid acceptance testing. Automated tests use synthetic
fixtures and do not establish authenticated Claude service/native acceptance.
+9
View File
@@ -0,0 +1,9 @@
import { mkdir, copyFile, rm } from 'node:fs/promises';
import { URL } from 'node:url';
const staging = new URL('./.package/', import.meta.url);
await rm(staging, { recursive: true, force: true });
await mkdir(new URL('./dist/', staging), { recursive: true });
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', staging));
for (const name of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) {
await copyFile(new URL(name, import.meta.url), new URL(name, staging));
}
@@ -0,0 +1,29 @@
{
"manifestVersion": 1,
"id": "com.netcatty.globalagent",
"name": "global-agent-example",
"displayName": "Global Agent Chat Example",
"description": "Custom sandbox Chat UI reusing Netcatty's configured Agent backends and explicitly selected terminal targets.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"main": { "browser": "dist/index.js" },
"activationEvents": ["onView:com.netcatty.globalagent.panel"],
"permissions": { "required": ["commands", "views", "ai.agent.use"], "optional": ["ai.agent.actions"] },
"features": { "required": ["ai.agent-sessions"] },
"contributes": {
"commands": [
{ "id": "com.netcatty.globalagent.backends", "title": "Global Agent: List Backends" },
{ "id": "com.netcatty.globalagent.targets", "title": "Global Agent: Select Targets" },
{ "id": "com.netcatty.globalagent.chats", "title": "Global Agent: List Conversations" },
{ "id": "com.netcatty.globalagent.open", "title": "Global Agent: Open Conversation" },
{ "id": "com.netcatty.globalagent.send", "title": "Global Agent: Send" },
{ "id": "com.netcatty.globalagent.read", "title": "Global Agent: Read Events" },
{ "id": "com.netcatty.globalagent.history", "title": "Global Agent: Read Conversation" },
{ "id": "com.netcatty.globalagent.stop", "title": "Global Agent: Stop" },
{ "id": "com.netcatty.globalagent.close", "title": "Global Agent: Close Conversation" }
],
"views": [{ "id": "com.netcatty.globalagent.panel", "title": "Global Agent Chat", "location": "modal", "entry": "panel.html" }]
}
}
@@ -0,0 +1 @@
{"name":"@netcatty/example-global-agent-plugin","version":"0.1.0","private":true,"type":"module","scripts":{"build":"node build.mjs"}}
+29
View File
@@ -0,0 +1,29 @@
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
* { box-sizing:border-box; }
body { margin:0; }
main { max-width:760px; padding:36px; margin:auto; }
header { margin-bottom:26px; }
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
#model { font-weight:600; }
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
textarea { resize:vertical; min-height:84px; }
form { margin:22px 0; }
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
button:disabled { opacity:.42; cursor:default; }
#choose-reference { margin-top:10px; }
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
.response { min-height:160px; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
footer { padding-top:8px; }
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
+8
View File
@@ -0,0 +1,8 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Global Agent Chat</title><link rel="stylesheet" href="panel.css"></head>
<body><main><header><span class="badge">AGENT PLATFORM EXAMPLE</span><h1>Global Agent Chat</h1><p>A conversation independent of the active terminal or workspace.</p><p id="qa" class="hint"></p></header>
<section class="card"><label for="backend">Configured Agent backend</label><select id="backend" disabled></select><button id="refresh" type="button">Refresh backends</button>
<label for="conversation">Owned conversation</label><select id="conversation"><option value="">New conversation</option></select>
<div class="actions"><button id="targets" type="button">Choose terminals</button><button id="open" type="button" disabled>Open conversation</button></div><p id="selection" class="hint">No targets selected yet.</p></section>
<section class="card response"><pre id="transcript" aria-live="polite"></pre><p id="usage" class="hint"></p><p id="activity" class="hint"></p></section>
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" maxlength="16384" placeholder="Ask about the selected terminals"></textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><span id="status" role="status">Loading configured backends…</span></div></form>
<footer>Core owns Agent execution and conversation history. Actions follow this plugin's Allow / Ask / Deny setting. Only Catty and Claude Code MCP mode are eligible in this version; other native modes are unavailable here.</footer></main><script src="panel.js"></script></body></html>
+93
View File
@@ -0,0 +1,93 @@
/* global window, document */
'use strict';
const element = (id) => document.getElementById(id);
const invoke = (name, params = {}) => window.netcattyView.executeCommand(`com.netcatty.globalagent.${name}`, params);
let chat; let selection; let turn; let busy = false; let generation = 0; let closed = false;
const option = (value, text) => { const item = document.createElement('option'); item.value = value; item.textContent = text; return item; };
const status = (text) => { element('status').textContent = text; };
function controls(value) {
busy = value;
for (const id of ['backend', 'conversation', 'refresh', 'targets', 'prompt']) element(id).disabled = value;
element('open').disabled = value || !selection || !element('backend').value;
element('send').disabled = value || !chat;
element('stop').disabled = !value || !chat;
}
async function closeChat() { const previous = chat; chat = undefined; if (previous) await invoke('close', { chatHandle: previous.chatHandle }).catch(() => {}); }
async function refresh() {
if (busy || closed) return; const run = ++generation; controls(true);
try {
await closeChat();
const [backends, chats] = await Promise.all([invoke('backends'), invoke('chats')]);
if (closed || run !== generation) return;
element('backend').replaceChildren(...backends.items.map((item) => {
const entry = option(item.status === 'supported' ? item.backendRef : '', `${item.label}${item.status === 'supported' ? '' : ` (${item.status}: ${item.reason})`}`);
entry.disabled = item.status !== 'supported'; return entry;
}));
const supported = backends.items.find((item) => item.status === 'supported'); element('backend').value = supported?.backendRef ?? '';
element('conversation').replaceChildren(option('', 'New conversation'), ...chats.items.map((item) => option(item.conversationRef, `${item.title} · ${item.backend}`)));
status('Choose terminals, then open a conversation.');
} catch { if (!closed && run === generation) status('Configured Agent unavailable. Check Netcatty settings, then Refresh backends.'); }
finally { if (!closed && run === generation) controls(false); }
}
element('refresh').addEventListener('click', refresh);
for (const id of ['backend', 'conversation']) element(id).addEventListener('change', async () => { await closeChat(); controls(false); status('Open the selected conversation before sending.'); });
element('targets').addEventListener('click', async () => {
if (busy) return; const run = ++generation; controls(true);
try {
await closeChat(); const result = await invoke('targets');
if (closed || run !== generation) return; selection = result;
element('selection').textContent = result.items.length ? result.items.map((item) => item.title).join(', ') : 'No terminal targets. Model conversation only.';
status('Targets selected. Open a conversation.');
} catch { if (!closed && run === generation) status('Target selection ended. Choose terminals again when ready.'); }
finally { if (!closed && run === generation) controls(false); }
});
element('open').addEventListener('click', async () => {
if (busy || !selection) return;
if (selection.expiresAt <= Date.now()) { status('Target selection expired. Choose terminals again.'); return; }
const run = ++generation; controls(true);
try {
await closeChat(); const reference = element('conversation').value;
const result = await invoke('open', { backendRef: element('backend').value, selectionHandle: selection.selectionHandle, ...(reference ? { conversationRef: reference } : {}) });
if (closed || run !== generation) { await invoke('close', { chatHandle: result.chatHandle }).catch(() => {}); return; }
chat = result; const history = await invoke('history', { chatHandle: chat.chatHandle });
if (closed || run !== generation) return;
element('transcript').textContent = history.items.map((item) => `${item.role}: ${item.text}`).join('\n\n');
status(`Ready · ${result.backendLabel}`);
} catch { await closeChat(); if (!closed && run === generation) status('Conversation unavailable. Refresh backends or choose terminals again; your draft is kept.'); }
finally { if (!closed && run === generation) controls(false); }
});
element('compose').addEventListener('submit', async (event) => {
event.preventDefault(); if (busy || !chat || !element('prompt').value.trim()) return;
if (chat.expiresAt <= Date.now()) { status('Conversation targets expired. Choose terminals and reopen; your draft is kept.'); return; }
const run = ++generation; controls(true); status('Running…'); element('usage').textContent = ''; element('activity').textContent = '';
const text = element('prompt').value; element('transcript').textContent += `\n\nuser: ${text}\n\nassistant: `;
try {
const result = await invoke('send', { chatHandle: chat.chatHandle, text });
if (closed || run !== generation) { await invoke('stop', { turnHandle: result.turnHandle }).catch(() => {}); return; }
turn = result.turnHandle;
for (;;) {
const item = await invoke('read', { turnHandle: turn }); if (closed || run !== generation) return;
if (item.kind === 'end') { status(item.reason === 'completed' ? 'Complete' : `Ended: ${item.reason}`); break; }
if (item.kind === 'text') element('transcript').textContent += item.text;
if (item.kind === 'activity') element('activity').textContent = `${item.capabilityId}: ${item.state}`;
if (item.kind === 'usage') element('usage').textContent = `${item.inputTokens} input tokens · ${item.outputTokens} output tokens`;
}
} catch { if (!closed && run === generation) status('Request ended. Check access or refresh/reselect before another explicit Send; your draft is kept.'); }
finally { if (!closed && run === generation) { turn = undefined; controls(false); } }
});
element('stop').addEventListener('click', async () => {
if (!busy) return; const run = ++generation; const previous = turn; turn = undefined; status('Stopping…');
try { if (previous) await invoke('stop', { turnHandle: previous }); else await closeChat(); }
catch { /* Revocation may already have stopped this exact turn. */ }
finally { if (!closed && run === generation) { controls(false); status('Stopped'); } }
});
const theme = window.netcattyView.onDidChangeEnvironment?.((environment) => {
const root = document.documentElement; root.dataset.theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
root.style.colorScheme = root.dataset.theme === 'system' ? 'light dark' : root.dataset.theme;
for (const name of ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground']) {
const value = environment.themeTokens?.[name]; if (typeof value === 'string' && value.length <= 96) root.style.setProperty(name, value); else root.style.removeProperty(name);
}
});
window.netcattyView.onDidReceiveMessage?.((message) => { if (message?.qaFixture === true) element('qa').textContent = 'Isolated deterministic fixture · no real model inference or paid API'; });
window.addEventListener('pagehide', () => { closed = true; ++generation; theme?.dispose(); if (turn) void invoke('stop', { turnHandle: turn }).catch(() => {}); void closeChat(); });
void refresh();
@@ -0,0 +1,41 @@
"use strict";
/* global __dirname, setImmediate */
const test = require("node:test"); const assert = require("node:assert/strict");
const fs = require("node:fs"); const path = require("node:path"); const { JSDOM } = require("jsdom");
test("global Chat sample follows host theme and preserves drafts on explicit target expiry without re-sharing or retrying", async (t) => {
const dom = new JSDOM(fs.readFileSync(path.join(__dirname, "panel.html"), "utf8"), { runScripts: "outside-only" });
let now = 1; let environment; let selections = 0; let sends = 0; let disposed = 0;
const calls = [];
dom.window.Date.now = () => now;
Object.defineProperty(dom.window, "netcattyView", { value: {
onDidChangeEnvironment: (callback) => { environment = callback; return { dispose: () => disposed++ }; }, onDidReceiveMessage: () => ({ dispose() {} }),
executeCommand: async (name) => {
const op = name.split(".").at(-1); calls.push(op);
if (op === "backends") return { items: [{ backendRef: "host-ref", label: "Synthetic configured Catty", status: "supported", reason: "ready" }] };
if (op === "chats" || op === "history") return { items: [] };
if (op === "targets") { selections++; return { selectionHandle: "selection", expiresAt: now + 300_000, items: [{ title: "Synthetic terminal" }] }; }
if (op === "open") return { chatHandle: "chat", backendLabel: "Synthetic Catty", expiresAt: now + 300_000 };
if (op === "send") { sends++; throw Error("Synthetic denied send"); }
return { ok: true };
},
} });
t.after(() => { dom.window.dispatchEvent(new dom.window.Event("pagehide")); dom.window.close(); });
const element = (id) => dom.window.document.getElementById(id);
const settle = async () => { for (let i = 0; i < 8; i++) await new Promise(setImmediate); };
dom.window.eval(fs.readFileSync(path.join(__dirname, "panel.js"), "utf8")); await settle();
environment({ theme: "light", themeTokens: { "--primary": "120 70% 40%" } });
assert.equal(dom.window.document.documentElement.dataset.theme, "light"); assert.equal(dom.window.document.documentElement.style.getPropertyValue("--primary"), "120 70% 40%");
environment({ theme: "dark", themeTokens: {} }); assert.equal(dom.window.document.documentElement.dataset.theme, "dark");
assert.equal(dom.window.document.documentElement.style.getPropertyValue("--primary"), "");
element("targets").click(); await settle(); element("open").click(); await settle();
element("prompt").value = "Keep this synthetic draft"; now += 300_001;
const send = () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true }));
send(); await settle(); assert.match(element("status").textContent, /expired/); assert.equal(sends, 0); assert.equal(selections, 1);
element("refresh").click(); await settle(); element("open").click(); await settle();
assert.match(element("status").textContent, /expired/); assert.equal(selections, 1); assert.equal(element("prompt").value, "Keep this synthetic draft");
element("targets").click(); await settle(); element("open").click(); await settle();
send(); await settle(); assert.equal(sends, 1); assert.equal(selections, 2); assert.match(element("status").textContent, /Request ended/);
await settle(); assert.equal(sends, 1, "a failed inference is never automatically retried");
assert.equal(element("prompt").value, "Keep this synthetic draft"); assert.ok(calls.includes("history")); assert.equal(disposed, 0);
});
@@ -0,0 +1,9 @@
export default {
activate(context) {
const operations = { backends: 'listBackends', targets: 'selectTargets', chats: 'listChats', open: 'openChat',
send: 'send', read: 'read', history: 'history', stop: 'stop', close: 'closeChat' };
for (const [name, operation] of Object.entries(operations)) context.subscriptions.add(context.commands.registerCommand(
`com.netcatty.globalagent.${name}`, (params = {}) => context.agent[operation](params),
));
},
};
Vendored
+3
View File
@@ -480,6 +480,9 @@ declare global {
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
setPluginAgentHostReady?(ready: boolean): Promise<boolean>;
respondPluginAgentHost?(response: import("@netcatty/plugin-contract").PluginAgentHostResponse): Promise<boolean>;
onPluginAgentHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAgentHostRequest) => void): () => void;
respondPluginAIHost?(response: import("@netcatty/plugin-contract").PluginAIHostResponse): Promise<boolean>;
onPluginAIHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAIHostRequest) => void): () => void;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
+3 -3
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,
@@ -163,8 +163,8 @@ export interface PanelBridge extends NetcattyBridge {
codexRuntime?: 'sdk' | 'app-server',
force?: boolean,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
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 }>;
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
aiSdkAgentSteer?: (
+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>;
}
+1 -1
View File
@@ -110,7 +110,7 @@ function buildScopeDescription(
case 'workspace':
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any session within this workspace.`;
case 'global':
return `You have global scope and can operate on any connected session across all workspaces.`;
return `You have global scope across workspaces, restricted to the explicitly provided sessions and authorized tools. Use only listed session IDs; never infer a target from the active tab.`;
}
}
+6 -1
View File
@@ -33,6 +33,7 @@ import {
type ToolResultDedup,
} from './toolResultDedup';
import cattyToolSpecs from './generated/cattyToolSpecs.json';
import globalAgentToolSpecs from './generated/globalAgentToolSpecs.json';
import {
cattyToolContextSchema,
toolDepsFromContext,
@@ -739,6 +740,7 @@ export function createCattyToolsFromCatalog(
chatSessionId?: string,
toolOutputStore?: ToolOutputStore,
toolResultDedup?: ToolResultDedup,
selection?: { agentKind?: import('../agentKinds').AgentKind; allowedCapabilityIds?: readonly string[] },
): CattyToolsBundle {
const sharedContext = buildCattyToolContext({
bridge,
@@ -754,7 +756,10 @@ export function createCattyToolsFromCatalog(
const catalogTools: Record<string, ReturnType<typeof tool>> = {};
const toolsContext: Record<string, CattyToolContext> = {};
for (const rawSpec of cattyToolSpecs as CattyToolSpec[]) {
const selectedSpecs = selection?.agentKind === 'global' ? globalAgentToolSpecs : cattyToolSpecs;
const allowed = selection?.allowedCapabilityIds ? new Set(selection.allowedCapabilityIds) : null;
for (const rawSpec of selectedSpecs as CattyToolSpec[]) {
if (allowed && !allowed.has(rawSpec.capabilityId)) continue;
if (rawSpec.capabilityId === 'harness.web.search' && !isWebSearchReady(webSearchConfig)) {
continue;
}
@@ -16,7 +16,8 @@ export const cattyRuntimeContextSchema = z.object({
modelId: z.string().optional(),
userGoal: z.string().optional(),
permissionMode: z.enum(['observer', 'confirm', 'auto']),
scopeType: z.enum(['terminal', 'workspace']),
scopeType: z.enum(['terminal', 'workspace', 'global']),
delegated: z.boolean().optional(),
scopeLabel: z.string().optional(),
lastCompaction: z.custom<CompactionTrace>().optional(),
lastStepAdjusted: z.boolean().optional(),
@@ -46,7 +47,8 @@ export function createInitialCattyRuntimeContext(input: {
modelId?: string;
userGoal?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeType: 'terminal' | 'workspace' | 'global';
delegated?: boolean;
scopeLabel?: string;
promptContext?: PromptContextSnapshot;
}): CattyRuntimeContext {
@@ -54,6 +56,7 @@ export function createInitialCattyRuntimeContext(input: {
chatSessionId: input.chatSessionId,
turnId: input.turnId,
agentKind: input.agentKind ?? 'sidebar',
delegated: input.delegated,
providerId: input.providerId,
modelId: input.modelId,
userGoal: input.userGoal,
@@ -2313,5 +2313,44 @@
"bypassesChatCancel": false
},
"agentKind": "global"
},
{
"capabilityId": "harness.terminal.read_context",
"toolName": "terminal_read_context",
"rpcMethod": "netcatty/readContext",
"localExecution": false,
"description": "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
"inputShape": {
"sessionId": {
"type": "string",
"optional": true,
"description": "Terminal session ID. Required when the current AI scope contains more than one terminal."
},
"range": {
"type": "string",
"optional": true,
"description": "Which terminal slice to read: viewport, tail, head, or lines. Defaults to viewport."
},
"startLine": {
"type": "number",
"optional": true,
"description": "Zero-based terminal buffer line to start from when range is lines."
},
"maxLines": {
"type": "number",
"optional": true,
"description": "Maximum terminal rows to return. Defaults to 80, capped at 300."
}
},
"policy": {
"write": false,
"sensitiveRead": false,
"longRunning": false,
"requiresChatSession": true,
"bypassesObserverBlock": false,
"bypassesApproval": true,
"bypassesChatCancel": true
},
"agentKind": "global"
}
]
@@ -2,11 +2,11 @@ import type { AIPermissionMode } from '../types';
export interface PromptContextSnapshot {
version: 2;
audience: 'sidebar';
audience: import('../agentKinds').AgentKind;
providerId?: string;
modelId?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeType: 'terminal' | 'workspace' | 'global';
scopeLabel?: string;
toolNames: string[];
selectedSkillSlugs: string[];
@@ -28,10 +28,11 @@ export interface PromptContextSnapshot {
}
export function buildPromptContextSnapshot(input: {
agentKind?: import('../agentKinds').AgentKind;
providerId?: string;
modelId?: string;
permissionMode: AIPermissionMode;
scopeType: 'terminal' | 'workspace';
scopeType: 'terminal' | 'workspace' | 'global';
scopeLabel?: string;
toolNames: string[];
selectedSkillSlugs?: string[];
@@ -52,7 +53,7 @@ export function buildPromptContextSnapshot(input: {
];
return {
version: 2,
audience: 'sidebar',
audience: input.agentKind ?? 'sidebar',
providerId: input.providerId,
modelId: input.modelId,
permissionMode: input.permissionMode,
@@ -145,7 +145,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
tools,
toolsContext,
runtimeContext,
toolApproval: buildCattyToolApproval({
toolApproval: runtimeContext.delegated ? undefined : buildCattyToolApproval({
permissionMode: runtimeContext.permissionMode,
chatSessionId: runtimeContext.chatSessionId,
}),
@@ -523,7 +523,8 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
case 'error': {
const typedChunk = chunk as ErrorChunk;
if (isSdkStreamStateError(typedChunk.error)) {
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
if (runtimeContext.delegated) console.warn('[Plugin Agent] SDK stream state unavailable');
else console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
break;
}
if (isRequestTooLargeError(typedChunk.error)) {
@@ -148,7 +148,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
if (attachments?.length && netcattyBridge.aiMcpUpdateAttachments) {
await netcattyBridge.aiMcpUpdateAttachments(attachments, sessionId);
}
const userSkillsContext = await resolveUserSkillsContext(
const userSkillsContext = context.delegated ? '' : await resolveUserSkillsContext(
netcattyBridge,
trimmed,
context.selectedUserSkillSlugs,
@@ -168,6 +168,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
sessionId,
ctx.toolOutputStore,
ctx.toolResultDedup,
{ agentKind: context.agentKind, allowedCapabilityIds: context.allowedCapabilityIds },
);
const { tools } = toolsBundle;
@@ -187,6 +188,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
const promptContext = buildPromptContextSnapshot({
agentKind: context.agentKind,
providerId: context.activeProvider.providerId,
modelId: activeModelId,
permissionMode: context.permissionMode ?? context.globalPermissionMode,
@@ -251,6 +253,8 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
: undefined,
streamIdleTimeoutMs: responseIdleTimeoutMs,
chatSessionId: sessionId,
requestIdPrefix: context.delegated && context.delegationTag
? `agent-model_${sessionId.slice('plugin-agent:'.length)}_${context.delegationTag}_` : undefined,
},
), privacy, {
// Compaction can run before prepareMessagesForStream selects replay
@@ -261,7 +265,8 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
],
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
if (context.delegated) console.error('[Plugin Agent] Model setup unavailable');
else console.error('[Catty] Model creation failed:', e);
ui.reportStreamError(sessionId, signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
@@ -437,6 +442,8 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
emitContextSnapshot(messagesForStream);
const runtimeContext = createInitialCattyRuntimeContext({
agentKind: context.agentKind,
delegated: context.delegated,
chatSessionId: sessionId,
turnId: ctx.turnId,
providerId: context.activeProvider?.providerId,
@@ -510,7 +517,8 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
throw streamErr;
}
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
@@ -556,7 +564,9 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
await runStream(retryMessages, retryAssistantMsgId);
}
} catch (err) {
console.error('[Catty] streamText error:', err);
if (context.delegated && signal.aborted) return;
if (context.delegated) console.error('[Plugin Agent] Turn unavailable');
else console.error('[Catty] streamText error:', err);
ui.reportStreamError(sessionId, signal, err);
} finally {
ui.updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
@@ -109,13 +109,13 @@ async function runExternalTurn(
return;
}
const userSkillsContext = await resolveUserSkillsContext(
const userSkillsContext = context.delegationTag ? '' : await resolveUserSkillsContext(
netcattyBridge,
trimmed,
context.selectedUserSkillSlugs,
);
const requestId = ctx.turnId;
const requestId = context.delegationTag ? `plugin-agent_${context.delegationTag}_${ctx.turnId}` : ctx.turnId;
ui.setStreamingForScope(sessionId, true);
if (netcattyBridge.aiMcpUpdateSessions) {
@@ -56,7 +56,12 @@ export interface CattyTurnContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
reasoningEffort?: string;
scopeType: 'terminal' | 'workspace';
scopeType: 'terminal' | 'workspace' | 'global';
agentKind?: import('../../agentKinds').AgentKind;
/** Trusted host only: tools still pass the main source-bound dispatcher. */
delegated?: boolean;
delegationTag?: string;
allowedCapabilityIds?: readonly string[];
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
@@ -74,6 +79,7 @@ export interface CattyTurnContext {
}
export interface ExternalTurnContext {
delegationTag?: string;
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ConfiguredPluginAgentBackends, getConfiguredCattyAgentModel, prepareConfiguredCattyAgentModel } from './configuredBackends';
import * as keys from '../../config/storageKeys';
const config = { id: 'synthetic', name: 'Synthetic', providerId: 'custom' as const, enabled: true, defaultModel: 'fixture', apiKey: 'FIXTURE_ONLY_MARKER' };
test('Catty backend follows existing Agent overrides and syncs host configuration without opening the sidebar', async (t) => {
const values = new Map<string, string>(); const old = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); const windowOld = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: (key: string) => values.get(key) ?? null } });
const providers = [config, { ...config, id: 'override', name: 'Override', defaultModel: 'override-default' }];
values.set(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers)); values.set(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, config.id);
values.set(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'active-only');
values.set(keys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: 'override' }));
values.set(keys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: 'agent-specific' }));
let syncs = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { aiSyncProviders: async (items: unknown[]) => {
assert.deepEqual(items, providers); syncs++; return { ok: true };
} } } });
t.after(() => { for (const [key, descriptor] of [['localStorage', old], ['window', windowOld]] as const) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
} });
const selected = getConfiguredCattyAgentModel()!; assert.equal(selected.config.id, 'override'); assert.equal(selected.config.defaultModel, 'agent-specific');
await prepareConfiguredCattyAgentModel(selected.revision); assert.equal(syncs, 1);
values.set(keys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: 'changed' })); await assert.rejects(prepareConfiguredCattyAgentModel(selected.revision));
assert.equal(syncs, 1);
});
test('backend refs isolate runtimes, expire with controlled time and invalidate native/model configuration changes', () => {
let time = 1; let revision = '1'; let externalModel = 'synthetic-native-model';
const external = { id: 'claude-local', name: 'Claude synthetic', command: '/synthetic/claude', enabled: true, available: true, sdkBackend: 'claude', env: { PRIVATE: 'PRIVATE_ENV' } };
const backends = new ConfiguredPluginAgentBackends({ getModel: () => ({ config, label: 'Synthetic', revision }),
prepareModel: async () => {}, getExternal: () => [external, { ...external, id: 'codex', sdkBackend: 'codex' }],
getExternalModel: () => externalModel, clock: () => time });
const rows = backends.list('r1'); const catty = rows.find((item) => item.backend === 'catty')!; const claude = rows.find((item) => item.backend === 'claude')!;
assert.doesNotMatch(JSON.stringify(rows), /FIXTURE_ONLY_MARKER|PRIVATE_ENV|apiKey|command|env|claude-local|synthetic-native-model/);
assert.equal(rows.find((item) => item.backend === 'codex')?.status, 'unsupported');
assert.equal(backends.resolve(claude.hostHandle, 'r1').selectedModel, externalModel);
assert.throws(() => backends.resolve(catty.hostHandle, 'r2'));
revision = '2'; assert.throws(() => backends.resolve(catty.hostHandle, 'r1'));
externalModel = 'changed'; assert.throws(() => backends.resolve(claude.hostHandle, 'r1'));
const next = backends.list('r1').find((item) => item.backend === 'catty')!;
time += 300_001; assert.throws(() => backends.resolve(next.hostHandle, 'r1'));
const refreshed = backends.list('r1').find((item) => item.backend === 'catty')!; assert.notEqual(refreshed.hostHandle, next.hostHandle);
backends.releaseRuntime('r1'); assert.throws(() => backends.resolve(refreshed.hostHandle, 'r1')); backends.shutdown();
});
@@ -0,0 +1,115 @@
import type { PluginAgentBackendKind, PluginAgentHostBackend } from '@netcatty/plugin-contract';
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_EXTERNAL_AGENTS, STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_AGENT_MODEL_MAP } from '../../config/storageKeys';
import { getExternalAgentSdkBackend } from '../managedAgents';
import type { ExternalAgentConfig, ProviderConfig } from '../types';
import { netcattyBridge } from '../../services/netcattyBridge';
const externalKinds = ['claude', 'codex', 'copilot', 'cursor', 'codebuddy', 'opencode', 'grok', 'mimo'] as const;
type Model = ReturnType<typeof getConfiguredCattyAgentModel>;
// Match the existing Catty Agent's host-owned provider/model override settings.
// The plain model-invocation broker intentionally keeps its own existing choice.
export function getConfiguredCattyAgentModel() {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
const override = providers.find((entry) => entry.id === providerMap.catty);
const provider = override ?? providers.find((entry) => entry.id === localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER));
if (!provider?.enabled) return null;
const model = override ? modelMap.catty?.trim() || provider.defaultModel?.trim()
: provider.defaultModel?.trim() || localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL)?.trim();
if (!model) return null;
const config = { ...provider, defaultModel: model };
return { config, label: `${config.name} · ${model}`.slice(0, 160), revision: JSON.stringify(config) };
}
export async function prepareConfiguredCattyAgentModel(revision: string) {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
if (getConfiguredCattyAgentModel()?.revision !== revision) throw Error('Selected Agent configuration changed');
const bridge = netcattyBridge.require();
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok
|| getConfiguredCattyAgentModel()?.revision !== revision) throw Error('Host Agent provider configuration is unavailable');
}
export interface ConfiguredAgentBackend {
descriptor: PluginAgentHostBackend;
runtimeId: string;
expiresAt: number;
identity: string;
provider?: ProviderConfig;
external?: ExternalAgentConfig;
selectedModel?: string;
isCurrent: () => boolean;
prepare: () => Promise<void>;
}
// Trusted renderer only. Opaque references select existing settings; neither
// saved keys, env, executable paths nor config fingerprints are returned in DTOs.
export class ConfiguredPluginAgentBackends {
private readonly refs = new Map<string, ConfiguredAgentBackend>();
constructor(private readonly options: {
getModel?: () => Model;
prepareModel?: (revision: string) => Promise<void>;
getExternal?: () => ExternalAgentConfig[];
getExternalModel?: (id: string) => string | undefined;
clock?: () => number;
} = {}) {}
private now() { return (this.options.clock ?? Date.now)(); }
private model() { return (this.options.getModel ?? getConfiguredCattyAgentModel)(); }
private external() { return (this.options.getExternal ?? (() => localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []))(); }
private externalModel(id: string) { return (this.options.getExternalModel ?? ((key) =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP)?.[key]))(id)?.trim() || undefined; }
list(runtimeId: string): PluginAgentHostBackend[] {
for (const [id, entry] of this.refs) if (entry.expiresAt <= this.now()) this.refs.delete(id);
const selected = this.model();
const result: PluginAgentHostBackend[] = [];
const add = (backend: PluginAgentBackendKind, label: string, status: PluginAgentHostBackend['status'],
reason: PluginAgentHostBackend['reason'], extras: Omit<ConfiguredAgentBackend, 'descriptor' | 'runtimeId' | 'expiresAt' | 'identity'>,
identity = 'unconfigured') => {
const existing = [...this.refs.values()].find((entry) => entry.runtimeId === runtimeId
&& entry.identity === identity && entry.descriptor.backend === backend && entry.descriptor.label === label.slice(0, 160)
&& entry.descriptor.status === status && entry.descriptor.reason === reason);
if (existing) { result.push(existing.descriptor); return; }
if (this.refs.size >= 256) throw Error('Agent backend reference budget exceeded');
const descriptor: PluginAgentHostBackend = { hostHandle: crypto.randomUUID(), backend, label: label.slice(0, 160), status, reason };
this.refs.set(descriptor.hostHandle, { descriptor, runtimeId, expiresAt: this.now() + 300_000, identity, ...extras });
result.push(descriptor);
};
if (selected) {
add('catty', `Catty · ${selected.label}`, 'supported', 'ready', { provider: structuredClone(selected.config),
isCurrent: () => this.model()?.revision === selected.revision,
prepare: () => (this.options.prepareModel ?? prepareConfiguredCattyAgentModel)(selected.revision) }, selected.revision);
} else add('catty', 'Catty', 'unavailable', 'not-configured', { isCurrent: () => false, prepare: async () => {} });
const configured = this.external();
for (const kind of externalKinds) {
const entries = configured.filter((item) => getExternalAgentSdkBackend(item) === kind);
if (!entries.length && result.length < 32) {
add(kind, kind, 'unavailable', 'not-configured', { isCurrent: () => false, prepare: async () => {} });
}
for (const entry of entries) {
if (result.length >= 32) break;
const selectedModel = this.externalModel(entry.id);
const snapshot = JSON.stringify([entry, selectedModel]);
const available = entry.enabled && entry.available !== false;
add(kind, entry.name, !available ? 'unavailable' : kind === 'claude' ? 'supported' : 'unsupported',
!available ? 'configuration-unavailable' : kind === 'claude' ? 'ready' : 'native-boundary-unverified', {
external: structuredClone(entry), selectedModel,
isCurrent: () => JSON.stringify([this.external().find((item) => item.id === entry.id), this.externalModel(entry.id)]) === snapshot,
prepare: async () => {},
}, snapshot);
}
}
return result;
}
resolve(hostHandle: string, runtimeId: string): ConfiguredAgentBackend {
const entry = this.refs.get(hostHandle);
if (!entry || entry.runtimeId !== runtimeId || entry.expiresAt <= this.now()
|| entry.descriptor.status !== 'supported' || !entry.isCurrent()) throw Error('Configured Agent reference expired or changed');
return entry;
}
releaseRuntime(runtimeId: string) { for (const [id, entry] of this.refs) if (entry.runtimeId === runtimeId) this.refs.delete(id); }
shutdown() { this.refs.clear(); }
}
@@ -0,0 +1,78 @@
import test, { type TestContext } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import type { PluginAgentHostRequest, PluginAgentHostResult } from '@netcatty/plugin-contract';
import { AgentRuntime } from '../harness/agentRuntime';
import type { TurnDriver } from '../harness/turnDrivers/types';
import { ConfiguredPluginAgentBackends } from './configuredBackends';
import { createTrustedPluginAgentRuntime } from './trustedAgentRuntime';
function deferred() { let resolve!: () => void; const promise = new Promise<void>((done) => { resolve = done; }); return { resolve, promise }; }
function fixture(t: TestContext, driver: TurnDriver, prepare = async () => {}) {
const old = Object.getOwnPropertyDescriptor(globalThis, 'window');
let revision = '1';
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
aiExec: async () => ({ ok: true, stdout: '', stderr: '', exitCode: 0 }), aiSetChatSessionCancelled: async () => ({ ok: true }),
} } });
const backends = new ConfiguredPluginAgentBackends({ getModel: () => ({ config: { id: 'fixture', name: 'Fixture', providerId: 'custom', enabled: true, defaultModel: 'fixture', apiKey: 'FIXTURE_MARKER' }, label: 'Fixture', revision }),
prepareModel: prepare, getExternal: () => [] });
const runtime = createTrustedPluginAgentRuntime({ isActive: () => true, chooseTargets: async () => [], backends, runtime: new AgentRuntime({ drivers: [driver] }) });
const session = { conversationRef: crypto.randomUUID(), id: `plugin-agent:${crypto.randomUUID()}`, agentId: 'catty', title: 'Synthetic', scope: { type: 'global' }, messages: [], createdAt: 1, updatedAt: 1 };
const call = (kind: string, payload: unknown, requestId: string = crypto.randomUUID(), runtimeId = 'r1'): Promise<PluginAgentHostResult> => runtime.handle({ requestId, kind, payload, runtimeId, actionToken: 'fixture' } as PluginAgentHostRequest, new AbortController().signal);
t.after(() => { runtime.shutdown(); if (old) Object.defineProperty(globalThis, 'window', old); else Reflect.deleteProperty(globalThis, 'window'); });
const start = async (requestId?: string) => {
const result = await call('agent.backends', {}); assert.ok('backends' in result);
return call('agent.run', { session, backendHandle: result.backends[0].hostHandle, terminalSessions: [], text: 'Synthetic user intent', maxIterations: 12, delegationTag: crypto.randomUUID(), capabilities: [] }, requestId);
};
return { runtime, call, start, modelTo: (value: string) => { revision = value; } };
}
test('trusted runtime reuses actual AgentRuntime and projects bounded events without raw tool data', async (t) => {
const f = fixture(t, { backend: 'catty', run: async (input, context) => {
assert.equal(input.backend, 'catty'); if (input.backend !== 'catty') return;
assert.equal(input.context.scopeType, 'global'); assert.equal(input.context.agentKind, 'global'); assert.deepEqual(input.context.selectedUserSkillSlugs, []);
context.emit({ id: 'delta', type: 'model_delta', text: 'Synthetic output' } as Parameters<typeof context.emit>[0]);
context.emit({ id: 'tool', type: 'tool_call', toolName: 'terminal_execute', toolCallId: 't1', args: { command: 'DO_NOT_SERIALIZE_TOOL_ARGS' } } as Parameters<typeof context.emit>[0]);
context.emit({ id: 'usage', type: 'usage', promptTokens: 12, completionTokens: 6 } as Parameters<typeof context.emit>[0]);
input.ui.updateMessageById?.(input.chatSessionId, input.assistantMsgId, (message) => ({ ...message, content: 'Synthetic output', statusText: undefined }));
} });
const started = await f.start(); assert.ok('hostHandle' in started);
const events = [];
for (let i = 0; i < 10; i++) {
const value = await f.call('agent.next', started); assert.ok('event' in value); events.push(value.event);
// Real stream UI clears optional status fields with undefined. The trusted
// IPC boundary accepts JSON values only, so each persisted snapshot must
// normalize those omitted fields before it is submitted.
const { assertPluginJsonValue } = createRequire(import.meta.url)('../../../electron/plugins/jsonBoundary.cjs');
assert.doesNotThrow(() => assertPluginJsonValue(value, { maxBytes: 1_048_576 }));
if (value.event.kind === 'end') break;
}
assert.ok(events.some((item) => item.kind === 'text')); assert.ok(events.some((item) => item.kind === 'activity'));
assert.doesNotMatch(JSON.stringify(events), /DO_NOT_SERIALIZE|FIXTURE_MARKER|apiKey|command/);
await assert.rejects(f.call('agent.next', started, undefined, 'foreign-runtime'));
});
test('cancelled start replies and runtime release abort exact turns, dropping late events', async (t) => {
const entered = deferred(); const resume = deferred(); let signal: AbortSignal | undefined;
const f = fixture(t, { backend: 'catty', run: async (_input, context) => { signal = context.signal; entered.resolve(); await resume.promise; context.emit({ id: 'late', type: 'model_delta', text: 'LATE_TEXT' } as Parameters<typeof context.emit>[0]); } });
const requestId = crypto.randomUUID(); const result = await f.start(requestId); await entered.promise;
f.runtime.cancelRequest(requestId, 'foreign-runtime'); assert.equal(signal?.aborted, false);
f.runtime.cancelRequest(requestId, 'r1'); assert.equal(signal?.aborted, true); resume.resolve();
await assert.rejects(f.call('agent.next', result));
});
test('release during backend preparation prevents a late AgentRuntime start', async (t) => {
const entered = deferred(); const resume = deferred(); let starts = 0;
const f = fixture(t, { backend: 'catty', run: async () => { starts++; } }, async () => { entered.resolve(); await resume.promise; });
const pending = f.start(); const rejected = assert.rejects(pending); await entered.promise;
f.runtime.releaseRuntime('r1'); resume.resolve(); await rejected; assert.equal(starts, 0);
});
test('host configuration changes abort a pending existing runtime turn and suppress late model text', async (t) => {
const resume = deferred(); let signal: AbortSignal | undefined;
const f = fixture(t, { backend: 'catty', run: async (_input, context) => { signal = context.signal; await resume.promise;
context.emit({ id: 'late', type: 'model_delta', text: 'LATE_MODEL_TEXT' } as Parameters<typeof context.emit>[0]); } });
const started = await f.start(); f.modelTo('2'); f.runtime.invalidateConfiguration(); assert.equal(signal?.aborted, true);
resume.resolve(); const value = await f.call('agent.next', started); assert.ok('event' in value);
assert.deepEqual(value.event, { kind: 'end', reason: 'unavailable' });
});
@@ -0,0 +1,233 @@
import { PLUGIN_AGENT_CAPABILITIES, type PluginAgentEvent, type PluginAgentHostRequest, type PluginAgentHostResult, type PluginAgentHostSession, type PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import type { AISession, ChatMessage } from '../types';
import type { AgentEvent } from '../harness/types';
import type { AgentRuntime } from '../harness/agentRuntime';
import { getAgentRuntime } from '../harness/globalAgentRuntime';
import { stopAgentTurn } from '../harness/agentStop';
import { redactSecretsForModel } from '../harness/modelSecretRedaction';
import { getNetcattyBridge } from '../aiChatStreamingSupport';
import { ConfiguredPluginAgentBackends } from './configuredBackends';
import globalTools from '../harness/generated/globalAgentToolSpecs.json';
export const GLOBAL_PLUGIN_AGENT_CAPABILITIES = PLUGIN_AGENT_CAPABILITIES;
const tools = globalTools.filter((entry) => new Set<string>(GLOBAL_PLUGIN_AGENT_CAPABILITIES).has(entry.capabilityId));
const capabilityForTool = (name: string) => tools.find((entry) => entry.toolName === name || name.endsWith(`__${entry.toolName}`))?.capabilityId;
type Turn = {
handle: string; requestId: string; runtimeId: string; backendHandle: string; session: PluginAgentHostSession; controller: AbortController;
queue: PluginAgentEvent[]; done: boolean; live: boolean; reading: boolean; bytes: number;
wake: (() => void) | null; unsubscribe: () => void; timer: ReturnType<typeof setTimeout>;
reason?: 'completed' | 'cancelled' | 'unavailable' | 'budget-exceeded';
};
// Runs the existing AgentRuntime in the trusted application, never in a plugin.
// It owns only IPC projection/lifecycle; no new model or tool orchestration loop.
export function createTrustedPluginAgentRuntime(options: {
isActive: () => boolean;
chooseTargets: (signal: AbortSignal, runtimeId: string) => Promise<PluginAgentHostTerminal[]>;
backends?: ConfiguredPluginAgentBackends;
runtime?: AgentRuntime;
}) {
const backends = options.backends ?? new ConfiguredPluginAgentBackends();
const runtime = options.runtime ?? getAgentRuntime();
const turns = new Map<string, Turn>();
const sessionOwners = new Map<string, { runtimeId: string }>();
let closed = false;
const active = () => { if (closed || !options.isActive()) throw Error('Trusted Agent host unavailable'); };
const releaseChat = (id: string, runtimeId: string) => {
const owner = sessionOwners.get(id); if (!owner || owner.runtimeId !== runtimeId) return;
for (const [handle, turn] of turns) if (turn.session.id === id && turn.runtimeId === runtimeId) { stop(turn); turns.delete(handle); }
void runtime.waitForActiveTurn(id).finally(() => {
if (sessionOwners.get(id) === owner) { runtime.clearChatSession(id); sessionOwners.delete(id); }
});
};
const snapshot = (turn: Turn) => {
// Existing stream UI deliberately clears optional fields with undefined.
// Canonical private IPC and core documents carry JSON values only.
const value = JSON.parse(JSON.stringify(turn.session)) as PluginAgentHostSession;
value.messages = value.messages.map((message) => message && typeof message === 'object' && !Array.isArray(message)
&& typeof message.content === 'string' ? { ...message, content: redactSecretsForModel(message.content) } : message);
return value;
};
const stop = (turn: Turn, reason: Turn['reason'] = 'cancelled') => {
if (!turn.live) return;
turn.live = false; turn.done = true; turn.reason = reason; turn.queue.length = 0; clearTimeout(turn.timer); turn.unsubscribe();
// Unified Stop aborts model input, native SDK work and pending approvals.
// Main's exact-turn delegation lease owns native/worker cancellation. Do
// not issue broad chat-scoped IPC that could arrive after a replacement turn.
void stopAgentTurn({ chatSessionId: turn.session.id, abortController: turn.controller,
backend: turn.session.agentId === 'catty' ? 'catty' : 'external-sdk' }).catch(() => {});
turn.wake?.();
};
const owned = (handle: string, runtimeId: string) => {
active(); const turn = turns.get(handle);
if (!turn || turn.runtimeId !== runtimeId) throw Error('Agent turn unavailable');
return turn;
};
const push = (turn: Turn, event: PluginAgentEvent) => {
if (!turn.live || turn.controller.signal.aborted) return;
if (turn.queue.length >= 512) { stop(turn, 'budget-exceeded'); return; }
turn.queue.push(event); turn.wake?.();
};
const emit = (turn: Turn, event: AgentEvent) => {
if (event.chatSessionId !== turn.session.id || !turn.live || turn.controller.signal.aborted) return;
if (event.type === 'model_delta') {
const text = redactSecretsForModel(event.text);
turn.bytes += new TextEncoder().encode(text).byteLength;
if (turn.bytes > 262_144) { stop(turn, 'budget-exceeded'); return; }
let chunk = ''; let bytes = 0;
for (const point of text) {
const size = new TextEncoder().encode(point).byteLength;
if (bytes + size > 4096) { push(turn, { kind: 'text', text: chunk }); chunk = ''; bytes = 0; }
chunk += point; bytes += size;
}
if (chunk) push(turn, { kind: 'text', text: chunk });
} else if (event.type === 'tool_call' || event.type === 'tool_result') {
const capabilityId = capabilityForTool(event.toolName ?? '');
if (!capabilityId) { stop(turn, 'unavailable'); return; }
push(turn, { kind: 'activity', capabilityId,
state: event.type === 'tool_call' ? 'started' : event.isError ? 'denied' : 'completed' });
} else if (event.type === 'usage') {
push(turn, { kind: 'usage', inputTokens: Math.max(0, Math.floor(event.promptTokens || 0)), outputTokens: Math.max(0, Math.floor(event.completionTokens || 0)) });
}
};
return {
async handle(request: PluginAgentHostRequest, signal: AbortSignal): Promise<PluginAgentHostResult> {
active(); signal.throwIfAborted();
const { runtimeId, kind } = request;
if (kind === 'agent.backends') return { backends: backends.list(runtimeId) };
if (kind === 'agent.targets') {
const targets = await options.chooseTargets(signal, runtimeId);
active(); signal.throwIfAborted(); return { targets };
}
if (kind === 'agent.current') {
try { backends.resolve(request.payload.hostHandle, runtimeId); return { current: true }; }
catch { return { current: false }; }
}
if (kind === 'agent.run') {
if (turns.size >= 32) throw Error('Agent turn budget exceeded');
const payload = request.payload;
const backend = backends.resolve(payload.backendHandle, runtimeId);
await backend.prepare(); active(); signal.throwIfAborted();
if (backends.resolve(payload.backendHandle, runtimeId) !== backend) throw Error('Selected Agent configuration changed');
if ([...turns.values()].some((entry) => entry.session.id === payload.session.id && !entry.done)) throw Error('Agent chat is busy');
const session = structuredClone(payload.session);
const prior = structuredClone(session) as unknown as AISession;
const assistantMsgId = crypto.randomUUID();
session.messages.push({ id: crypto.randomUUID(), role: 'user', content: payload.text, timestamp: Date.now() },
{ id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now() });
const bridge = getNetcattyBridge();
if (!bridge) throw Error('Trusted Agent bridge unavailable');
const controller = new AbortController(); const handle = crypto.randomUUID();
const turn: Turn = { handle, requestId: request.requestId, runtimeId, backendHandle: payload.backendHandle, session, controller, queue: [], done: false, live: true,
reading: false, bytes: 0, wake: null, unsubscribe: () => {}, timer: setTimeout(() => {}, 0) };
clearTimeout(turn.timer); turn.timer = setTimeout(() => stop(turn, 'budget-exceeded'), 120_000);
turns.set(handle, turn);
sessionOwners.set(session.id, { runtimeId });
turn.unsubscribe = runtime.subscribe((event) => { try { emit(turn, event); } catch { stop(turn); } });
const update = (id: string, updater: (message: ChatMessage) => ChatMessage) => {
if (!turn.live || controller.signal.aborted) return;
turn.session.messages = turn.session.messages.map((value) => {
const message = value as unknown as ChatMessage;
return message.id === id ? updater(message) as unknown as typeof value : value;
});
turn.session.updatedAt = Date.now();
};
const ui = {
addMessageToSession: (_id: string, message: ChatMessage) => { if (turn.live && turn.session.messages.length < 200) turn.session.messages.push(message as never); },
updateLastMessage: (_id: string, updater: (message: ChatMessage) => ChatMessage) => {
const last = turn.session.messages.at(-1) as unknown as ChatMessage | undefined; if (last) update(last.id, updater);
},
updateMessageById: (_id: string, id: string, updater: (message: ChatMessage) => ChatMessage) => update(id, updater),
reportStreamError: () => { push(turn, { kind: 'end', reason: 'unavailable' }); },
setStreamingForScope: () => {},
getLatestSession: () => turn.session as unknown as AISession,
persistContextCompaction: (_id: string, value: AISession['contextCompaction']) => { if (turn.live) turn.session.contextCompaction = value as never; },
};
const tagged = {
...bridge,
aiExec: (id: string, command: string, chatId?: string) => bridge.aiExec(id, command, chatId, payload.delegationTag),
aiCapability: (method: string, params: Record<string, unknown>, chatId?: string) => bridge.aiCapability!(method, params, chatId, payload.delegationTag),
aiCattyCancelExec: (chatId: string) => bridge.aiCattyCancelExec?.(chatId, payload.delegationTag),
aiSetChatSessionCancelled: (chatId: string, cancelled?: boolean) => bridge.aiSetChatSessionCancelled!(chatId, cancelled, payload.delegationTag),
aiMcpUpdateSessions: (sessions: typeof payload.terminalSessions, chatId?: string) => bridge.aiMcpUpdateSessions?.(sessions.map((item) => ({ ...item, hostId: item.hostId ?? '' })), chatId, payload.delegationTag),
};
const common = { chatSessionId: session.id, userText: payload.text, assistantMsgId, signal: controller.signal, ui, bridge: tagged };
const run = backend.provider
? runtime.runTurn({ ...common, backend: 'catty', sendScopeKey: `global:${session.id}`, currentSession: prior,
maxIterations: payload.maxIterations, context: {
activeProvider: backend.provider, activeModelId: backend.provider.defaultModel,
scopeType: 'global', agentKind: 'global', delegated: true, globalPermissionMode: 'confirm',
delegationTag: payload.delegationTag, selectedUserSkillSlugs: [],
allowedCapabilityIds: payload.capabilities,
terminalSessions: payload.terminalSessions,
getExecutorContext: () => ({ sessions: payload.terminalSessions.map((item) => ({ ...item, hostId: item.hostId ?? '' })),
readTerminalContext: async (params) => tagged.aiCapability('netcatty/readContext', params as unknown as Record<string, unknown>, session.id) as never }),
autoTitleSession: () => {}, responseIdleTimeout: 30,
} })
: runtime.runTurn({ ...common, backend: 'external-sdk', agentConfig: backend.external!, attachedImages: [], context: {
// Delegated Claude disables native session persistence. Its
// existing driver rebuilds the prompt from this core history;
// never resume builtin or another backend's native session ID.
updateExternalSessionId: () => {},
historyMessages: prior.messages.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({ role: message.role as 'user' | 'assistant', content: message.content })),
terminalSessions: payload.terminalSessions, providers: [], toolIntegrationMode: 'mcp', permissionMode: 'confirm',
delegationTag: payload.delegationTag, selectedUserSkillSlugs: [], selectedAgentModel: backend.selectedModel,
} });
void run.then((result) => {
push(turn, { kind: 'end', reason: result.reason === 'completed' ? 'completed' : result.reason === 'aborted' ? 'cancelled' : 'unavailable' });
}, () => { push(turn, { kind: 'end', reason: 'unavailable' }); }).finally(() => {
turn.done = true; turn.unsubscribe(); clearTimeout(turn.timer); turn.wake?.();
});
return { hostHandle: handle };
}
if (kind === 'agent.next') {
const turn = owned(request.payload.hostHandle, runtimeId);
try { backends.resolve(turn.backendHandle, runtimeId); } catch { stop(turn, 'unavailable'); }
if (turn.reading) throw Error('Agent read already pending');
turn.reading = true;
const cancelled = () => stop(turn); signal.addEventListener('abort', cancelled, { once: true });
try {
if (!turn.queue.length && !turn.done) await new Promise<void>((resolve) => { turn.wake = resolve; });
active(); signal.throwIfAborted();
try { backends.resolve(turn.backendHandle, runtimeId); } catch { stop(turn, 'unavailable'); }
const event = turn.queue.shift();
if (event) return { done: false, event, session: snapshot(turn) };
return { done: false, event: { kind: 'end', reason: turn.reason ?? 'completed' }, session: snapshot(turn) };
} finally { turn.wake = null; turn.reading = false; signal.removeEventListener('abort', cancelled); }
}
if (kind === 'agent.stop' || kind === 'agent.release') {
const turn = owned(request.payload.hostHandle, runtimeId); stop(turn);
if (kind === 'agent.release') turns.delete(turn.handle);
return { ok: true, session: snapshot(turn) };
}
throw Error('Unsupported Agent host request');
},
cancelRequest(requestId: string, runtimeId: string) {
// A timed-out start reply may never deliver its handle to main. Correlate
// cleanup with that exact request, including after handle() has returned.
for (const [id, turn] of turns) if (turn.runtimeId === runtimeId && turn.requestId === requestId) { stop(turn); turns.delete(id); }
},
invalidateConfiguration() {
for (const turn of turns.values()) {
try { backends.resolve(turn.backendHandle, turn.runtimeId); } catch { stop(turn, 'unavailable'); }
}
},
releaseRuntime(runtimeId: string) {
for (const [id, turn] of turns) if (turn.runtimeId === runtimeId) { stop(turn); turns.delete(id); }
for (const [id, owner] of sessionOwners) if (owner.runtimeId === runtimeId) {
releaseChat(id, runtimeId);
}
backends.releaseRuntime(runtimeId);
},
releaseChat,
shutdown() {
closed = true; for (const turn of turns.values()) stop(turn); turns.clear(); backends.shutdown();
for (const [id, owner] of sessionOwners) void runtime.waitForActiveTurn(id).finally(() => {
if (sessionOwners.get(id) === owner) { runtime.clearChatSession(id); sessionOwners.delete(id); }
});
},
};
}
@@ -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(() => {
+19 -2
View File
@@ -44,6 +44,10 @@ interface BridgeAPI {
headers: Record<string, string>,
body?: string,
providerId?: string,
skipHostCheck?: boolean,
followRedirects?: boolean,
skipTLSVerify?: boolean,
requestId?: string,
): Promise<{
ok: boolean;
status: number;
@@ -494,9 +498,13 @@ export function createBridgeFetchForSDK(
))
: undefined;
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');
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `${requestContext?.requestIdPrefix ?? ''}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
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);
@@ -649,7 +657,16 @@ export function createBridgeFetchForSDK(
}
// Non-streaming path
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
const fetchId = delegatedId ? `${requestContext!.requestIdPrefix}fetch_${crypto.randomUUID()}` : undefined;
const cancel = () => { if (fetchId) void bridge.aiChatCancel(fetchId).catch(() => {}); };
resolvedInit?.signal?.throwIfAborted();
if (fetchId) resolvedInit?.signal?.addEventListener('abort', cancel, { once: true });
let result;
try {
result = fetchId ? await bridge.aiFetch(url, method, headers, requestBody, providerId, false, false, false, fetchId)
: await bridge.aiFetch(url, method, headers, requestBody, providerId);
resolvedInit?.signal?.throwIfAborted();
} finally { resolvedInit?.signal?.removeEventListener('abort', cancel); }
return new Response(normalizeOpenAIChatCacheUsage(result.data), {
status: result.status,
+8
View File
@@ -138,6 +138,10 @@
"name": "@netcatty/example-assistant-plugin",
"version": "0.1.0"
},
"examples/plugins/global-agent": {
"name": "@netcatty/example-global-agent-plugin",
"version": "0.1.0"
},
"examples/plugins/hello-netcatty": {
"name": "@netcatty/example-hello-plugin",
"version": "0.1.0",
@@ -4684,6 +4688,10 @@
"resolved": "examples/plugins/assistant",
"link": true
},
"node_modules/@netcatty/example-global-agent-plugin": {
"resolved": "examples/plugins/global-agent",
"link": true
},
"node_modules/@netcatty/example-hello-plugin": {
"resolved": "examples/plugins/hello-netcatty",
"link": true
+1 -1
View File
@@ -57,7 +57,7 @@
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"test:plugin-examples": "node --test examples/plugins/database-minimal/*.test.cjs examples/plugins/database-rfam-readonly/*.test.cjs examples/plugins/terminal-selection-draft/prepare.test.cjs",
"test:plugin-examples": "node --test examples/plugins/database-minimal/*.test.cjs examples/plugins/database-rfam-readonly/*.test.cjs examples/plugins/terminal-selection-draft/prepare.test.cjs examples/plugins/global-agent/*.test.cjs",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs",
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
@@ -3745,9 +3745,10 @@
"displayContext": {
"type": "object", "additionalProperties": false,
"properties": {
"action": { "$ref": "#/$defs/PluginAgentActionPreview" },
"modelLabel": { "type": "string", "minLength": 1, "maxLength": 160 },
"selections": {
"type": "array", "maxItems": 5,
"type": "array", "maxItems": 32,
"items": {
"type": "object", "required": ["title", "scope", "version"], "additionalProperties": false,
"properties": {
@@ -5404,6 +5405,791 @@
"additionalProperties": false
}
]
},
"PluginAgentHostBackend": {
"type": "object",
"required": [
"hostHandle",
"label",
"backend",
"status",
"reason"
],
"properties": {
"hostHandle": {
"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
},
"PluginAgentHostTerminal": {
"type": "object",
"required": [
"sessionId",
"hostname",
"label",
"connected"
],
"properties": {
"sessionId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"hostId": {
"type": "string",
"maxLength": 128
},
"hostname": {
"type": "string",
"maxLength": 256
},
"label": {
"type": "string",
"maxLength": 160
},
"protocol": {
"type": "string",
"maxLength": 32
},
"username": {
"type": "string",
"maxLength": 256
},
"connected": {
"type": "boolean"
}
},
"additionalProperties": false
},
"PluginAgentHostSession": {
"type": "object",
"required": [
"conversationRef",
"id",
"title",
"agentId",
"scope",
"messages",
"createdAt",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"maxLength": 128,
"pattern": "^plugin-agent:[0-9a-f-]+$"
},
"title": {
"type": "string",
"maxLength": 160
},
"agentId": {
"type": "string",
"maxLength": 128
},
"scope": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"const": "global"
}
},
"additionalProperties": false
},
"messages": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/JsonValue"
}
},
"createdAt": {
"type": "integer",
"minimum": 0
},
"updatedAt": {
"type": "integer",
"minimum": 0
},
"externalSessionId": {
"type": "string",
"maxLength": 4096
},
"contextCompaction": {
"$ref": "#/$defs/JsonValue"
}
},
"additionalProperties": false
},
"PluginAgentHostRequest": {
"description": "Private main-to-trusted-app Agent envelopes. Never exposed in plugin views. Configuration and credentials remain in the host.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.backends"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.targets"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.current"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.run"
},
"payload": {
"type": "object",
"required": [
"backendHandle",
"delegationTag",
"session",
"terminalSessions",
"text",
"capabilities",
"maxIterations"
],
"properties": {
"backendHandle": {
"type": "string",
"format": "uuid"
},
"delegationTag": {
"type": "string",
"format": "uuid"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
},
"terminalSessions": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostTerminal"
}
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384
},
"capabilities": {
"type": "array",
"maxItems": 6,
"uniqueItems": true,
"items": {
"enum": [
"session.environment",
"terminal.execute",
"terminal.start",
"terminal.poll",
"terminal.stop",
"harness.terminal.read_context"
]
}
},
"maxIterations": {
"type": "integer",
"minimum": 1,
"maximum": 12
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.next"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.stop"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.release"
},
"payload": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "request.cancel"
},
"payload": {
"type": "object",
"required": [
"requestId"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "runtime.release"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "agent.close"
},
"payload": {
"type": "object",
"required": [
"chatSessionId"
],
"properties": {
"chatSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^plugin-agent:[0-9a-f-]+$"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PluginAgentHostResult": {
"oneOf": [
{
"type": "object",
"required": [
"backends"
],
"properties": {
"backends": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostBackend"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"targets"
],
"properties": {
"targets": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentHostTerminal"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"current"
],
"properties": {
"current": {
"type": "boolean"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"event"
],
"properties": {
"done": {
"const": false
},
"event": {
"$ref": "#/$defs/PluginAgentEvent"
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"session"
],
"properties": {
"done": {
"const": true
},
"session": {
"$ref": "#/$defs/PluginAgentHostSession"
}
},
"additionalProperties": false
}
]
},
"PluginAgentHostResponse": {
"description": "Private one-use trusted application Agent response. Core history may include native resume identity; public plugin responses must project it out.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"result"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"result": {
"$ref": "#/$defs/PluginAgentHostResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"error"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"error": {
"const": true
}
},
"additionalProperties": false
}
]
},
"PluginAgentCapabilities": {
"const": [
"session.environment",
"terminal.execute",
"terminal.start",
"terminal.poll",
"terminal.stop",
"harness.terminal.read_context"
]
},
"PluginAgentActionPreview": {
"type": "object",
"required": [
"capabilityId",
"text"
],
"additionalProperties": false,
"properties": {
"capabilityId": {
"type": "string",
"maxLength": 160
},
"text": {
"type": "string",
"maxLength": 98304
}
}
}
}
}
@@ -522,6 +522,7 @@ export type PermissionRequest = {
resources?: Array<PermissionResource>;
resourceKinds?: Array<PermissionResourceKind>;
displayContext?: {
action?: PluginAgentActionPreview;
modelLabel?: string;
selections?: Array<{
title: string;
@@ -554,6 +555,11 @@ export type PermissionSet = {
optional?: Array<PermissionDeclaration>;
};
export type PluginAgentActionPreview = {
capabilityId: string;
text: string;
};
export type PluginAgentBackend = {
backendRef: string;
label: string;
@@ -568,6 +574,8 @@ export type PluginAgentBackendsResult = {
items: Array<PluginAgentBackend>;
};
export type PluginAgentCapabilities = ["session.environment","terminal.execute","terminal.start","terminal.poll","terminal.stop","harness.terminal.read_context"];
export type PluginAgentChatRef = {
chatHandle: string;
};
@@ -612,6 +620,151 @@ export type PluginAgentHistoryResult = {
items: Array<PluginAgentMessage>;
};
export type PluginAgentHostBackend = {
hostHandle: string;
label: string;
backend: PluginAgentBackendKind;
status: "supported" | "unsupported" | "unavailable";
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
};
export type PluginAgentHostRequest = ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.backends";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.targets";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.current";
payload: {
hostHandle: string;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.run";
payload: {
backendHandle: string;
delegationTag: string;
session: PluginAgentHostSession;
terminalSessions: Array<PluginAgentHostTerminal>;
text: string;
capabilities: Array<"session.environment" | "terminal.execute" | "terminal.start" | "terminal.poll" | "terminal.stop" | "harness.terminal.read_context">;
maxIterations: number;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.next";
payload: {
hostHandle: string;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.stop";
payload: {
hostHandle: string;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.release";
payload: {
hostHandle: string;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "request.cancel";
payload: {
requestId: string;
};
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "runtime.release";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "agent.close";
payload: {
chatSessionId: string;
};
});
export type PluginAgentHostResponse = ({
requestId: string;
actionToken: string;
result: PluginAgentHostResult;
}) | ({
requestId: string;
actionToken: string;
error: true;
});
export type PluginAgentHostResult = ({
backends: Array<PluginAgentHostBackend>;
}) | ({
targets: Array<PluginAgentHostTerminal>;
}) | ({
current: boolean;
}) | ({
hostHandle: string;
}) | ({
ok: boolean;
session?: PluginAgentHostSession;
}) | ({
done: false;
event: PluginAgentEvent;
session?: PluginAgentHostSession;
}) | ({
done: true;
session: PluginAgentHostSession;
});
export type PluginAgentHostSession = {
conversationRef: string;
id: string;
title: string;
agentId: string;
scope: {
type: "global";
};
messages: Array<JsonValue>;
createdAt: number;
updatedAt: number;
externalSessionId?: string;
contextCompaction?: JsonValue;
};
export type PluginAgentHostTerminal = {
sessionId: string;
hostId?: string;
hostname: string;
label: string;
protocol?: string;
username?: string;
connected: boolean;
};
export type PluginAgentMessage = {
role: "user" | "assistant";
text: string;
+1
View File
@@ -56,6 +56,7 @@ export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease
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_AGENT_CAPABILITIES = ["session.environment", "terminal.execute", "terminal.start", "terminal.poll", "terminal.stop", "harness.terminal.read_context"] as const satisfies import("./generated/plugin-contract.js").PluginAgentCapabilities;
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;
@@ -0,0 +1,54 @@
import http from 'node:http';
import { pathToFileURL } from 'node:url';
// Deterministic isolated QA only. No inference, outbound network, persistence,
// request/body/header logs, or actual terminal. A test-owned worker records tools.
export function createGlobalAgentProviderFixture({ delayMs = 20, toolName = 'terminal_execute', sessionId = 'synthetic-b' } = {}) {
const counts = { requests: 0, completed: 0, aborted: 0, rejected: 0 };
const allowed = new Set(['get_environment', 'terminal_execute', 'terminal_start', 'terminal_poll', 'terminal_stop', 'terminal_read_context']);
const server = http.createServer(async (request, response) => {
if (request.method === 'GET' && request.url === '/fixture/status') {
response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ fixture: true, inference: false, ...counts })); return;
}
if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { response.writeHead(404); response.end(); return; }
let payload; let body = ''; let bytes = 0;
try {
for await (const chunk of request) { bytes += chunk.length; if (bytes > 1_048_576) throw Error('Bounds'); body += chunk; }
payload = JSON.parse(body);
if (payload.model !== 'global-agent-fixture' || payload.stream !== true || !Array.isArray(payload.messages)
|| payload.tools?.some((entry) => !allowed.has(entry.function?.name))) throw Error('Shape');
} catch { counts.rejected++; response.writeHead(400); response.end(); return; }
counts.requests++;
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' });
const send = (delta, finish = null) => response.write(`data: ${JSON.stringify({ id: 'global-agent-fixture', object: 'chat.completion.chunk', created: 1,
model: 'global-agent-fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
const hasResult = payload.messages.some((message) => message.role === 'tool');
const requestTool = !hasResult && payload.tools?.some((entry) => entry.function?.name === toolName);
const lastUser = [...payload.messages].reverse().find((message) => message.role === 'user');
const wait = typeof lastUser?.content === 'string' && lastUser.content.includes('fixture:wait');
let finished = false; let index = 0;
const parts = wait ? Array(80).fill('Synthetic waiting output. ') : ['Synthetic global ', 'Agent result.'];
const timer = setInterval(() => {
if (requestTool) {
const args = toolName === 'get_environment' ? {} : { sessionId, command: 'printf synthetic_fixture' };
send({ tool_calls: [{ index: 0, id: 'fixture_call', type: 'function', function: { name: toolName, arguments: JSON.stringify(args) } }] });
send({}, 'tool_calls');
} else if (index < parts.length) { send({ content: parts[index++] }); return; }
else send({}, 'stop');
response.write(`data: ${JSON.stringify({ id: 'global-agent-fixture', object: 'chat.completion.chunk', created: 1,
model: 'global-agent-fixture', choices: [], usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 } })}\n\n`);
finished = true; counts.completed++; clearInterval(timer); response.end('data: [DONE]\n\n');
}, delayMs);
response.once('close', () => { clearInterval(timer); if (!finished) counts.aborted++; });
});
return { server, counts };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const i = process.argv.indexOf('--port'); const port = i < 0 ? 9218 : Number(process.argv[i + 1]);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('Invalid fixture port');
const { server } = createGlobalAgentProviderFixture({ toolName: 'get_environment', delayMs: 250 });
server.listen(port, '127.0.0.1', () => process.stdout.write(`DETERMINISTIC_AGENT_FIXTURE http://127.0.0.1:${port}/v1\nNo model inference or paid API.\n`));
server.on('error', () => { process.stderr.write('Fixture could not bind its loopback port.\n'); process.exitCode = 1; });
process.once('SIGINT', () => server.close()); process.once('SIGTERM', () => server.close());
}