mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e682dc83 | ||
|
|
2671cf9f20 | ||
|
|
672a2bf406 | ||
|
|
f5e2a64dfb | ||
|
|
25e0c834da | ||
|
|
f8616f5d3a | ||
|
|
0cab1046cf | ||
|
|
a56d1ab785 | ||
|
|
cbe89995e6 | ||
|
|
72383a50c9 | ||
|
|
8bcad28f9d | ||
|
|
59edc87ed4 | ||
|
|
5944a02238 | ||
|
|
b17713ef95 | ||
|
|
56fefdd03f | ||
|
|
b7e4abb30b | ||
|
|
f715a0764d | ||
|
|
5db0f90751 | ||
|
|
63f0572e0b | ||
|
|
ea619555ac | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
056164157f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
// Invalidate main-owned backend leases too, even with no event read in
|
||||
// flight. Re-advertising availability does not renew existing handles.
|
||||
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
|
||||
}
|
||||
};
|
||||
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 };
|
||||
}
|
||||
@@ -207,7 +207,9 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
|
||||
// the sample's pending callbacks before teardown closes its document.
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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)); });
|
||||
} };
|
||||
let viewOpen = true;
|
||||
const mountPanel = async () => {
|
||||
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) => {
|
||||
// PluginViewHost.close removes sender authority before WebContents
|
||||
// closes. A pagehide RPC cannot be relied on to reach this relay.
|
||||
if (!viewOpen) throw Error('Untrusted plugin view sender');
|
||||
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')));
|
||||
};
|
||||
await mountPanel();
|
||||
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'));
|
||||
let 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 = 'modal-reopen-history';
|
||||
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
|
||||
const beforeReopen = counts.requests;
|
||||
viewOpen = false;
|
||||
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
|
||||
viewOpen = true; await mountPanel();
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
|
||||
await act(async () => element('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(() => /^(Ready|Conversation unavailable)/.test(element('status').textContent!));
|
||||
assert.match(element('status').textContent!, /^Ready/, 'ordinary modal reopen must release the prior exact chat even if pagehide cleanup never reaches the plugin');
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(service.agentService.chats.size, 1); assert.equal(counts.requests, beforeReopen, 'history reopen does not invoke a model');
|
||||
prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic continuation';
|
||||
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';
|
||||
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']);
|
||||
phase = 'backend-revision';
|
||||
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
|
||||
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
|
||||
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
|
||||
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
|
||||
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
|
||||
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
|
||||
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -100,6 +100,21 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
|
||||
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
|
||||
await act(async () => {
|
||||
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
|
||||
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
|
||||
}); await settle();
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /Operation:.*get_environment/);
|
||||
assert.match(dialog.textContent!, /session\.environment/);
|
||||
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
|
||||
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
|
||||
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
|
||||
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import globalAgentToolSpecs from '../../infrastructure/ai/harness/generated/globalAgentToolSpecs.json';
|
||||
|
||||
/** Rendered only by the trusted app, never inside a plugin view. */
|
||||
export function PluginPermissionHost() {
|
||||
@@ -12,6 +13,9 @@ export function PluginPermissionHost() {
|
||||
if (!prompt) return null;
|
||||
const description = describePluginPermission(prompt.permission, resolvedLocale);
|
||||
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
|
||||
const action = prompt.displayContext?.action;
|
||||
const operation = action && (globalAgentToolSpecs.find((tool) => tool.capabilityId === action.capabilityId)?.toolName ?? action.capabilityId);
|
||||
const selections = prompt.displayContext?.selections ?? [];
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
|
||||
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
@@ -20,9 +24,17 @@ 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?.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>)}
|
||||
{action && <div className="space-y-1">
|
||||
<p className="text-sm break-words">{zh ? '操作' : 'Operation'}: <code>{operation}</code>{operation !== action.capabilityId && <> ({action.capabilityId})</>}</p>
|
||||
<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">{action.text}</pre>
|
||||
</div>}
|
||||
{(selections.length > 0 || prompt.permission === 'ai.agent.actions') && <div className="space-y-1">
|
||||
<p className="text-sm">{zh ? '目标' : 'Targets'}:{selections.length === 0 && (zh ? ' 未选择目标' : ' No targets selected')}</p>
|
||||
{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>)}
|
||||
</div>}
|
||||
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
|
||||
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
|
||||
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
|
||||
|
||||
@@ -68,6 +68,11 @@ 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);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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
|
||||
`f0edf43523845847d5dbb74b0c4d8cd3bfe8e0f89ca193f28140e6b47dc45ed0`.
|
||||
Two independent package builds matched. Build scripts and tests are excluded
|
||||
from the six-file installed package.
|
||||
|
||||
The sample reserves transcript space inside the actual 798-by-548-pixel plugin
|
||||
viewport measured during native QA. In a scheduled native test environment, run
|
||||
`npm run test:plugin-global-agent-layout` for the bounded render regression. It
|
||||
loads the real sample HTML/CSS with synthetic history, including the fixture
|
||||
notice, and checks transcript scrolling, reachable configuration, and visible
|
||||
mouse Stop. It does not load the plugin script or start an Agent/provider. This
|
||||
render check remains pending where the normal browser sandbox cannot start;
|
||||
do not disable the sandbox to run it. Also verify the installed sample visually
|
||||
at the 1188-by-848-pixel app window before accepting the viewport correction.
|
||||
|
||||
For the modal-history lifecycle regression, stop the synthetic stream, close
|
||||
the modal with the host X, and reopen it without disabling/restarting the
|
||||
plugin or waiting for five-minute lease expiry. Select the owned conversation,
|
||||
confirm fresh empty targets, and Open. History must load without another model
|
||||
request. Repeat with a new conversation. The sample command runtime retains
|
||||
only its last host-issued chat handle and releases that exact lease on explicit
|
||||
Open; it does not change core expiry, source ownership or duplicate-open guards.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Global Agent plugin bridge (work in progress)
|
||||
|
||||
This stack starts from `integration/plugin-ask-routing-acceptance` at
|
||||
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
|
||||
its native Ask acceptance and integrated CI are still pending. This branch does
|
||||
not modify the frozen candidate, #3986, #3998, or #4002.
|
||||
|
||||
The user approved a global conversation independent of a terminal or workspace,
|
||||
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
|
||||
Agent execution, history, target authority, native approvals, credentials and
|
||||
audit. Global is not unrestricted inventory access or a background service.
|
||||
|
||||
## Stage 1: dispatch authority
|
||||
|
||||
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
|
||||
register plugin APIs, start an Agent or implement an orchestration loop.
|
||||
|
||||
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
|
||||
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
|
||||
synchronous current-owner checks, and a supported capability subset. The public
|
||||
result contains only an opaque handle and expiry. Plugin parameters must never
|
||||
be used as `hostSnapshot`.
|
||||
- `begin(handle, context)` returns an internal turn ticket and AbortController.
|
||||
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
|
||||
adapter once, checks current target revisions after any approval wait, invokes
|
||||
the real dispatcher, and rejects late results. `resolvedAction` must come from
|
||||
canonical host capability/argument resolution, not an unchecked plugin object.
|
||||
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
|
||||
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
|
||||
- Handles expire in five minutes. There are at most eight per runtime owner and
|
||||
64 overall, with at most 32 explicit resources per scope. Expiry never silently
|
||||
refreshes a selection or revives an old turn.
|
||||
|
||||
The focused tests use the actual `PluginAISourceBinding` with synthetic host
|
||||
registries, two terminal owners representing different workspaces, controlled
|
||||
time, and deferred approval/native completion. They cover source/runtime/target
|
||||
substitution, focus changes, revision changes across approval, policy denial,
|
||||
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
|
||||
or AgentRuntime dispatch; those remain integration gates.
|
||||
|
||||
Initial execution failed before test discovery because the new worktree has no
|
||||
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
|
||||
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
|
||||
paid credentials or GUI was used.
|
||||
|
||||
The adjacent run then passed 49 tests and failed one installed-entrypoint test
|
||||
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
|
||||
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
|
||||
failures are setup evidence, not failed-before product regressions.
|
||||
|
||||
The core conversation repository stores the existing global `AISession` document
|
||||
shape in a host-only directory, namespaced by the hash of source/private-owner.
|
||||
Native session identity and compaction remain private. Commits serialize a cloned
|
||||
snapshot, revalidate provenance before atomic rename, and retain previous history
|
||||
when that check fails. Files use mode 0600 and no-follow reads. The repository
|
||||
does not register a plugin storage API or change the source database schema.
|
||||
|
||||
The canonical schema and SDK now declare the proposed session interface and
|
||||
permissions. Runtime methods and the supported-feature advertisement are not yet
|
||||
registered. This stage must not be described as an installable working Agent
|
||||
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
|
||||
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
|
||||
|
||||
## Independent stage-1 correction
|
||||
|
||||
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
|
||||
confirmed two helper defects before any runtime acceptance claim: source failure
|
||||
during the initial owner check rejected late work without aborting the active
|
||||
native signal; Stop could acquire a replacement turn after awaiting identity.
|
||||
|
||||
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
|
||||
the focused before run is 13 passed / 2 failed. The correction revokes the captured
|
||||
scope when its own host-authenticated identity fails, and captures the intended
|
||||
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
|
||||
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
|
||||
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
|
||||
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
|
||||
`/tmp/netcatty-global-agent-review-after.log`,
|
||||
`/tmp/netcatty-global-agent-review-node22.log`.
|
||||
|
||||
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
|
||||
after an explicit runner shutdown and operation cancellation, with no assertion
|
||||
failure in its log and no complete repository test total. Package workflow
|
||||
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
|
||||
it was requested once, producing Linux job `122048349748`, still running at this
|
||||
checkpoint. Original runner failure remains historical evidence. Neither that
|
||||
rerun nor the helper fix establishes runtime, native or independent-review
|
||||
acceptance; the correction still needs reviewer recheck.
|
||||
|
||||
### Follow-up: cancellation is not source revocation
|
||||
|
||||
The independent recheck confirmed the first two repairs and found a remaining
|
||||
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
|
||||
after B had started, revoking B's scope. The same failure was reproduced at both
|
||||
owner-validation await paths using the actual source binding. Exact-18377 source
|
||||
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
|
||||
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
|
||||
|
||||
The minimal follow-up distinguishes this request's abort/deadline from verified
|
||||
owner or resource failure, rethrows the access failure, and leaves the existing
|
||||
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
|
||||
regression confirms that a verified source mismatch still aborts native work
|
||||
even when the RPC is concurrently cancelled. Foreign caller protections remain.
|
||||
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
|
||||
24.19.0; focused lint and diff checks pass. Logs:
|
||||
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
|
||||
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
|
||||
|
||||
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
|
||||
operation-cancelled log and no assertion failure. No second rerun was requested.
|
||||
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
|
||||
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
|
||||
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
|
||||
repository total or an assertion failure. Both Windows jobs passed. No blind
|
||||
rerun of that job was made. Package workflow `40462595846` was still running when
|
||||
checked. These records are not substituted for exact-head green CI.
|
||||
|
||||
## Planned public contract and adapters
|
||||
|
||||
### Independent transport correction
|
||||
|
||||
The exact `ea619555` internal-handler reproductions fail all four targeted cases:
|
||||
both no-session and empty-selection environment reads serialize global tool hints;
|
||||
stale chat-wide cleanup cancels replacement B; and caller-supplied builtin chat
|
||||
text can downgrade cancellation of a main-owned delegated request. The outer
|
||||
sender gate still accepts only the current trusted main window. These are trusted
|
||||
application lifecycle defects, not arbitrary plugin IPC exposure or installed
|
||||
runtime exploit evidence.
|
||||
|
||||
Cancellation now resolves ownership from the main request map, rejects a
|
||||
conflicting caller chat, checks the recorded sender and current turn lease, and
|
||||
cancels only that native request. The legacy chat-wide cleanup IPC is unavailable
|
||||
for reserved delegated conversations; main-owned exact-turn release remains the
|
||||
cleanup authority. All delegated environment return paths omit global tool hints.
|
||||
|
||||
An additional Node 22 regression is independently reproduced: inline arrow
|
||||
properties at the beginning of `module.exports` prevent namespace detection of
|
||||
existing exports such as `init`, breaking the real Catty attachment harness.
|
||||
Identifier exports preserve that existing interface. Correction tests pass 94 on
|
||||
Node 22.23.3, including the actual Catty attachment consumer; focused lint passes.
|
||||
Failed-before logs are `/tmp/netcatty-global-agent-transport-review-four-failed-before.log`
|
||||
and `/tmp/netcatty-global-agent-ea619-namespace-before.log`. The transport PR's
|
||||
review checkpoint remains `ea619555` until the parent integrates/rechecks this
|
||||
separate correction. No native/backend/full-CI acceptance is implied.
|
||||
|
||||
### Stage 2A: trusted native dispatch boundary
|
||||
|
||||
The independent bounded recheck of foundation `ba4d3fc` passed all 20 reviewer
|
||||
cases. This resolves its reported helper findings; it does not establish full CI,
|
||||
runtime, backend or native acceptance.
|
||||
|
||||
The additive transport slice gives managed subprocesses a separate per-turn MCP
|
||||
credential, permanently binds their TCP connection to that turn, filters their
|
||||
tool list, and routes actual tool execution through the source-bound host policy
|
||||
adapter. Catty's trusted-app IPC enters the same dispatcher. Branded approval
|
||||
contexts skip the legacy Agent approval only after that host adapter grants the
|
||||
action; process-global permission mode is unchanged. Selected environment reads
|
||||
return the scoped session metadata without querying global tunnel inventory.
|
||||
|
||||
Managed Claude is restricted to its existing MCP mode with builtin tools disabled,
|
||||
strict MCP configuration and no inherited settings sources. The main native entry
|
||||
rejects unverified backends and Skills mode for reserved delegated conversations.
|
||||
Each model stream and native SDK request carries a main-owned turn tag; releasing
|
||||
that lease aborts its HTTP/native work. Old credentials, cancellation requests and
|
||||
disposers cannot acquire or cancel the next turn.
|
||||
|
||||
The focused boundary suite passes 116 cases, including an actual local TCP/main
|
||||
handler with synthetic workers, plus two new actual-HTTP/renderer-transport lease
|
||||
tests. These are deterministic transport tests, not real model inference or
|
||||
authenticated Claude service/CLI acceptance. Public runtime registration, core
|
||||
conversation integration and the custom chat sample are still separate work in
|
||||
progress; this transport slice alone is not a usable plugin Agent feature.
|
||||
|
||||
At exact `ba4d3fc`, package CI `40464058910` passed; test workflow `40464058908`
|
||||
is red. Linux job `122055141575` failed a real assertion in
|
||||
`PluginManagementDialog.interaction.test.tsx:71`: the external view handoff had
|
||||
not run when asserted. That stage reports 34 passed / 1 failed; earlier runtime
|
||||
and contract groups passed 720 and 5. Both Windows jobs passed. No blind rerun was
|
||||
requested. This unresolved assertion must not be classified as runner shutdown.
|
||||
|
||||
The wider initial local transport run reports 697 passed / 10 failed / 3 skipped.
|
||||
All ten failures are Cursor discovery expectations after `npm ci --ignore-scripts`
|
||||
left SQLite's native binding absent, making the bundled Cursor SDK import fail.
|
||||
The standard rebuild first failed using unwritable default caches; rebuilding
|
||||
with task-local npm/node-gyp cache paths succeeded. The failed totals are retained
|
||||
in `/tmp/netcatty-global-agent-transport-expanded.log`; the after-setup run is
|
||||
recorded separately. No GUI or provider credentials were used.
|
||||
|
||||
After the dependency repair, the expanded transport/native-handler/preload/SDK
|
||||
suite passes 709 tests, 0 failures, 3 skips. Log:
|
||||
`/tmp/netcatty-global-agent-transport-expanded-after-setup.log`. Focused lint passes.
|
||||
|
||||
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
|
||||
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
|
||||
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
|
||||
names, separate from the existing tools-disabled `ai.invoke` broker.
|
||||
|
||||
The API will provide backend listing, explicit target selection, owned chat
|
||||
listing/opening, sending, bounded event reads, history, Stop and close. Backend
|
||||
references are opaque host-issued configuration revisions. Conversation references
|
||||
identify only histories owned by the calling source/private-owner; runtime handles
|
||||
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
|
||||
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
|
||||
|
||||
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
|
||||
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
|
||||
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
|
||||
entries must return explicit unsupported/unconfigured status until their native
|
||||
tool and approval boundaries are verified. Codex SDK's current unrestricted native
|
||||
configuration must not be treated as eligible merely because it has a driver.
|
||||
|
||||
The terminal worker already exposes a suitable main-owned resource binding:
|
||||
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
|
||||
session generation. Use this for dispatch-time targets; do not use menu IDs,
|
||||
focused tab, display labels, or a renderer-provided session ID as authority.
|
||||
|
||||
The MCP dispatcher and Catty direct execution route need the same delegated
|
||||
session guard. A delegated call must not update process-global permission mode,
|
||||
or request both an Agent approval and a plugin approval for the same action.
|
||||
Policy changes, disable/restart/source replacement, lock and host disconnection
|
||||
must cancel scoped native work and invalidate outgoing event tickets. Existing
|
||||
Agent trace and plugin audit/log facilities will receive redacted correlation
|
||||
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
### 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.
|
||||
@@ -3,6 +3,8 @@
|
||||
// Presentation only. Admission and grants remain owned by the canonical schema
|
||||
// and permission engine. Tests require exact coverage of its permission enum.
|
||||
const PERMISSION_DESCRIPTIONS = {
|
||||
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
|
||||
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
|
||||
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
|
||||
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
|
||||
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
@@ -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); } }
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { createRequire, wrap } = require("node:module");
|
||||
|
||||
// Execute the real driver/registry source with only config repair and SDK
|
||||
// inference replaced. No user config is read and no SDK process is launched.
|
||||
function loadSource(name, replacements) {
|
||||
const filename = require.resolve(name); const local = createRequire(filename);
|
||||
const module = { exports: {} };
|
||||
const wrapper = vm.runInThisContext(wrap(fs.readFileSync(filename, "utf8")), { filename });
|
||||
wrapper(module.exports, (id) => replacements[id] ?? local(id), module, filename, path.dirname(filename));
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const repairs = []; const sends = []; const errors = [];
|
||||
const driver = loadSource("./claudeDriver.cjs", { "./claudeConfig.cjs": { ensureClaudeConfig: () => repairs.push("repair") } });
|
||||
const run = driver.runClaudeTurn;
|
||||
driver.runClaudeTurn = (args) => run({ ...args, queryFn: ({ options }) => {
|
||||
sends.push(options);
|
||||
return (async function* () { yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Synthetic" } } }; })();
|
||||
} });
|
||||
const registry = loadSource("./index.cjs", { "./claudeDriver.cjs": driver });
|
||||
const emitter = new Proxy({ emitError: (error) => errors.push(error) }, { get: (target, key) => target[key] ?? (() => {}) });
|
||||
const env = Object.freeze({ CLAUDE_CONFIG_DIR: "/synthetic/selected-profile", ANTHROPIC_BASE_URL: "http://127.0.0.1:1", ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" });
|
||||
const ctx = { prompt: "Synthetic", env, emitter, toolIntegrationMode: "mcp", cwd: "/synthetic/turn", binPath: "/synthetic/official-claude",
|
||||
abortController: new AbortController(), assertSessionValid: () => {}, injectedMcpServers: [{ name: "selected-only", command: "host-managed", args: [], env: [] }] };
|
||||
return { repairs, sends, errors, env, ctx, run: (overrides) => registry.getDriver("claude").runTurn({ ...ctx, ...overrides }) };
|
||||
}
|
||||
|
||||
for (const explicitProfile of [true, false]) test(`delegated registry turn skips config repair with ${explicitProfile ? "explicit" : "default"} host profile`, async () => {
|
||||
const f = fixture(); const env = explicitProfile ? f.env : { ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" };
|
||||
await f.run({ delegatedAgent: true, env });
|
||||
assert.deepEqual(f.repairs, []); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
|
||||
const options = f.sends[0]; assert.equal(options.env, env);
|
||||
assert.equal(options.cwd, f.ctx.cwd); assert.equal(options.pathToClaudeCodeExecutable, f.ctx.binPath);
|
||||
assert.deepEqual(options.tools, []); assert.deepEqual(options.settingSources, []);
|
||||
assert.equal(options.strictMcpConfig, true); assert.equal(options.persistSession, false);
|
||||
assert.deepEqual(Object.keys(options.mcpServers), ["selected-only"]);
|
||||
});
|
||||
|
||||
for (const delegatedAgent of [undefined, false]) test(`builtin registry turn retains repair when delegated flag is ${String(delegatedAgent)}`, async () => {
|
||||
const f = fixture(); await f.run({ delegatedAgent });
|
||||
assert.deepEqual(f.repairs, ["repair"]); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
|
||||
assert.equal(f.sends[0].env, f.env); assert.equal(f.sends[0].settingSources, undefined);
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
|
||||
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
|
||||
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
|
||||
* - Repairs ~/.claude.json before builtin turns only (ensureClaudeConfig).
|
||||
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
|
||||
* side-effect tools so the agent can only act through the injected netcatty
|
||||
* MCP server (approval/scope/blocklist enforced there).
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -233,8 +244,10 @@ function buildClaudePromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
|
||||
*/
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
ensureClaudeConfig();
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid, delegatedAgent = false }) {
|
||||
// A delegated turn may use the selected host profile for authentication, but
|
||||
// must not repair or delete unrelated files in the main process's home.
|
||||
if (!delegatedAgent) ensureClaudeConfig();
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
let sdk;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
// SDK queries in this file are synthetic. Never repair real user config.
|
||||
const configPath = require.resolve("./claudeConfig.cjs");
|
||||
const priorConfigModule = require.cache[configPath];
|
||||
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
|
||||
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
|
||||
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
|
||||
else delete require.cache[configPath];
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
@@ -333,3 +339,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/);
|
||||
});
|
||||
@@ -44,8 +44,10 @@ 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 });
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter,
|
||||
assertSessionValid: ctx.assertSessionValid, delegatedAgent: ctx.delegatedAgent === true });
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return claude.listClaudeModels({
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
// Load the query-only Claude fixture with config repair replaced. Its live
|
||||
// prompt guard tests must never read or repair the executor's actual profile.
|
||||
const configPath = require.resolve("./claudeConfig.cjs");
|
||||
const priorConfigModule = require.cache[configPath];
|
||||
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
|
||||
const claudeDriver = require("./claudeDriver.cjs");
|
||||
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
|
||||
else delete require.cache[configPath];
|
||||
|
||||
function fixture() {
|
||||
const privacy = createAiPrivacyService();
|
||||
@@ -172,7 +180,7 @@ for (const pausedMethod of ["initialize", "session/new"]) {
|
||||
|
||||
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
|
||||
const cases = [
|
||||
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["claude", (f) => claudeDriver.runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
|
||||
@@ -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,111 @@ 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("actual SDK entry derives delegated mode from main authority, ignoring caller flag spoofing", async (t) => {
|
||||
const { PluginAgentMcpDelegations } = require("../../../plugins/agent/mcpDelegation.cjs");
|
||||
const registry = new PluginAgentMcpDelegations(); t.after(() => registry.shutdown());
|
||||
const chat = "plugin-agent:11111111-1111-4111-8111-111111111111";
|
||||
const tag = "22222222-2222-4222-8222-222222222222";
|
||||
registry.register(chat, { turnTag: tag, senderId: 7, tools: [], assertCurrent() {}, dispatch() {} });
|
||||
const h = createPrivacyStreamHarness(createTestPrivacy(false));
|
||||
const directory = fs.mkdtempSync(nodePath.join(os.tmpdir(), "netcatty-claude-flag-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
h.ctx.tempDirBridge = { getTempDir: () => directory };
|
||||
Object.assign(h.ctx.mcpServerBridge, {
|
||||
isPluginAgentConversation: (id) => registry.isReserved(id),
|
||||
assertPluginAgentCaller: (...args) => registry.assertCaller(...args),
|
||||
getPluginAgentMcpConfiguration: (...args) => registry.configuration(...args),
|
||||
onPluginAgentRelease: (...args) => registry.onRelease(...args),
|
||||
});
|
||||
const handler = h.handlers.get("netcatty:ai:sdk-agent:stream");
|
||||
const payload = { requestId: `plugin-agent_${tag}_fixture`, chatSessionId: chat, sdkBackend: "claude", toolIntegrationMode: "mcp", prompt: "Synthetic", delegatedAgent: false };
|
||||
assert.equal((await handler({ sender: { id: 7 } }, payload)).ok, true);
|
||||
assert.equal(h.turns[0].delegatedAgent, true, "caller cannot turn off delegated protection");
|
||||
assert.equal((await h.stream({ delegatedAgent: true })).ok, true);
|
||||
assert.equal(h.turns[1].delegatedAgent, false, "builtin caller cannot opt into the delegated branch");
|
||||
await assert.rejects(handler({ sender: { id: 99 } }, { ...payload, requestId: `plugin-agent_${tag}_foreign`, delegatedAgent: true }));
|
||||
assert.equal(h.turns.length, 2, "a spoofed flag cannot replace current sender/turn authority");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
test("actual plugin broadcast skips destroyed WebContents while its BrowserWindow is still alive", () => {
|
||||
// Evaluate the actual registrar callback with narrow window fixtures. This
|
||||
// models the destruction-event ordering, not an installed Electron crash.
|
||||
const source = fs.readFileSync(path.join(__dirname, "../main/registerBridges.cjs"), "utf8");
|
||||
const callbacks = [...source.matchAll(/broadcast: \(channel, payload\) => \{([\s\S]*?)\n \},/g)];
|
||||
assert.equal(callbacks.length, 1);
|
||||
const received = [];
|
||||
const deadWindow = { isDestroyed: () => true, get webContents() { throw Error("Destroyed BrowserWindow getter"); } };
|
||||
const closingWindow = { isDestroyed: () => false, webContents: {
|
||||
isDestroyed: () => true, send() { throw Error("Object has been destroyed"); },
|
||||
} };
|
||||
const liveWindow = { isDestroyed: () => false, webContents: {
|
||||
isDestroyed: () => false, send: (...args) => received.push(args),
|
||||
} };
|
||||
const broadcast = vm.runInNewContext(`(channel, payload) => {${callbacks[0][1]}\n}`, {
|
||||
electronModule: { BrowserWindow: { getAllWindows: () => [deadWindow, closingWindow, liveWindow] } },
|
||||
});
|
||||
const payload = { host: [], session: [] };
|
||||
assert.doesNotThrow(() => broadcast("netcatty:plugins:scope-catalog-changed", payload));
|
||||
assert.deepEqual(received, [["netcatty:plugins:scope-catalog-changed", payload]]);
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -240,7 +240,7 @@ function createBridgeRegistrar(context) {
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
broadcast: (channel, payload) => {
|
||||
for (const window of electronModule.BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send(channel, payload);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -342,12 +343,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 ──
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"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.startupTurns = 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) {
|
||||
const startupLifetime = async (_context, next) => next();
|
||||
startupLifetime.prepareAuthorization = (context) => {
|
||||
if (context.method !== "agent.send") return undefined;
|
||||
// Registry cleanup runs after its final authority check. Until then the
|
||||
// caller still owns startup cancellation because no handle was delivered.
|
||||
return () => {
|
||||
const turn = this.startupTurns.get(context.permissionOperation);
|
||||
if (!turn) return;
|
||||
try {
|
||||
if (!turn.handoffReady || context.signal.aborted) throw fail("cancelled");
|
||||
this.#assertNow(turn);
|
||||
} catch { this.#end(turn); }
|
||||
turn.detachStartup();
|
||||
};
|
||||
};
|
||||
this.registrations.push(registry.use(startupLifetime));
|
||||
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");
|
||||
if (operation === "send") {
|
||||
const turn = this.startupTurns.get(context.permissionOperation);
|
||||
if (!turn || turn.handle !== result.turnHandle) throw fail("cancelled");
|
||||
turn.handoffReady = true;
|
||||
}
|
||||
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);
|
||||
// Ask may have waited while the configured backend was edited. Check the
|
||||
// trusted renderer's current revision again before the selected effect.
|
||||
try { await this.#backendCurrent(turn.chat.backend, ctx); }
|
||||
catch (error) { this.#end(turn, "unavailable"); throw error; }
|
||||
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);
|
||||
turn.context = { ...context, signal: controller.signal, assertActive: async () => this.#assertNow(turn) };
|
||||
const cancelStartup = () => this.#end(turn);
|
||||
turn.detachStartup = () => {
|
||||
context.signal?.removeEventListener("abort", cancelStartup);
|
||||
if (this.startupTurns.get(context.permissionOperation) === turn) this.startupTurns.delete(context.permissionOperation);
|
||||
};
|
||||
this.startupTurns.set(context.permissionOperation, turn);
|
||||
context.signal?.addEventListener("abort", cancelStartup, { once: true });
|
||||
try {
|
||||
context.signal?.throwIfAborted();
|
||||
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.detachStartup?.(); 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 },
|
||||
// Cleanup authenticates the owning source, independently of the ended
|
||||
// turn or its completed Send request. The host still checks app lifetime.
|
||||
{ ...turn.context, signal: undefined, assertActive: async () => this.source.assertCurrent(turn.owner) }).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,241 @@
|
||||
"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 releasedHandles = []; 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, context) => {
|
||||
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) };
|
||||
if (kind === "agent.release") { await context.assertActive(); releasedHandles.push(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, releasedHandles, 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.deepEqual(f.hostRequests.map((request) => request.kind), ["agent.backends"], "public backend listing never starts native model discovery");
|
||||
await assert.rejects(f.call("listBackends", { delegatedAgent: true }));
|
||||
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" });
|
||||
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Spoof delegated mode", delegatedAgent: false }));
|
||||
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("a duplicate owned-history Open cannot replace or cancel an active core chat", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open();
|
||||
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic active chat" });
|
||||
const active = f.service.turns.get(turn.turnHandle);
|
||||
await assert.rejects(f.open("catty", chat.conversationRef), { code: -32006 });
|
||||
assert.equal(f.service.chats.size, 1); assert.equal(active.chat.activeTurn, active);
|
||||
assert.equal(active.controller.signal.aborted, false); assert.equal(await f.dispatch(turn), "synthetic");
|
||||
});
|
||||
|
||||
test("backend revision changed during Ask cannot execute the already selected target without any pending event read", 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 pending approval" });
|
||||
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.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
|
||||
const pending = f.dispatch(turn); await entered.promise;
|
||||
const checksBeforeApproval = f.hostRequests.filter((item) => item.kind === "agent.current").length;
|
||||
f.currentTo(false); resume.resolve();
|
||||
const result = await pending.then(() => "executed", () => "rejected");
|
||||
assert.equal(f.effects(), 0); assert.equal(result, "rejected");
|
||||
assert.ok(f.hostRequests.filter((item) => item.kind === "agent.current").length > checksBeforeApproval);
|
||||
assert.equal(native.signal.aborted, true); assert.equal(internal.controller.signal.aborted, true);
|
||||
assert.equal(f.decisions.length, 1);
|
||||
});
|
||||
|
||||
test("empty-argument environment Ask carries the canonical host operation and selected-target disclosure", async (t) => {
|
||||
const f = fixture(t); f.choose(async () => ({ targets: [] })); f.modeTo("ask");
|
||||
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic environment" });
|
||||
const internal = f.service.turns.get(turn.turnHandle);
|
||||
await f.delegation.dispatch("netcatty/getContext", { chatSessionId: internal.chat.record.id },
|
||||
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, () => ({ hosts: [], hostCount: 0 }));
|
||||
assert.equal(f.decisions.length, 1);
|
||||
assert.deepEqual(f.decisions[0].displayContext.action, { capabilityId: "session.environment", text: "{}" });
|
||||
assert.deepEqual(f.decisions[0].displayContext.selections, []);
|
||||
});
|
||||
|
||||
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("registered send cancellation during trusted startup revokes the exact turn before a handle can be handed off", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
|
||||
const caller = new AbortController();
|
||||
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
|
||||
const pending = f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic cancelled startup" }, { signal: caller.signal });
|
||||
const rejected = assert.rejects(pending); await entered.promise;
|
||||
const internal = [...f.service.turns.values()][0]; const native = new AbortController();
|
||||
f.delegation.onRelease(internal.chat.record.id, internal.handle, () => native.abort());
|
||||
caller.abort(); resume.resolve(); await rejected;
|
||||
assert.equal(internal.chat.activeTurn, null); assert.equal(internal.controller.signal.aborted, true);
|
||||
assert.equal(f.delegation.has(internal.chat.record.id), false); assert.equal(native.signal.aborted, true);
|
||||
assert.equal(internal.hostHandle, null, "late startup completion cannot publish an orphan handle");
|
||||
assert.equal(f.service.startupTurns.size, 0);
|
||||
});
|
||||
|
||||
test("completed send detaches request lifetime from its live turn and cannot cancel replacement B", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const caller = new AbortController();
|
||||
const first = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic completed handoff" }, { signal: caller.signal });
|
||||
const a = f.service.turns.get(first.turnHandle); caller.abort();
|
||||
assert.equal(f.service.startupTurns.size, 0);
|
||||
assert.equal(a.controller.signal.aborted, false); assert.equal(await f.dispatch(first), "synthetic");
|
||||
await f.call("stop", { turnHandle: first.turnHandle });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(f.releasedHandles.includes(a.hostHandle), "completed request lifetime cannot block exact host-handle cleanup");
|
||||
const second = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic replacement B" });
|
||||
const b = f.service.turns.get(second.turnHandle); caller.abort();
|
||||
assert.equal(b.controller.signal.aborted, false); assert.equal(await f.dispatch(second), "synthetic");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
|
||||
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
|
||||
const open = createDefinitionValidator("PluginAgentOpenParams");
|
||||
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
|
||||
assert.equal(open(params), true);
|
||||
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
|
||||
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
|
||||
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
|
||||
assert.equal(open({ ...params, [key]: value }), false, key);
|
||||
}
|
||||
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
|
||||
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
|
||||
const send = createDefinitionValidator("PluginAgentSendParams");
|
||||
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
|
||||
assert.equal(send(params), true);
|
||||
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
|
||||
assert.equal(send({ ...params, text: "a\0b" }), false);
|
||||
assert.equal(send({ ...params, turnId: "builtin" }), false);
|
||||
const event = createDefinitionValidator("PluginAgentEvent");
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
|
||||
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
|
||||
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
|
||||
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
|
||||
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
|
||||
});
|
||||
|
||||
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
|
||||
const chats = createDefinitionValidator("PluginAgentChatsResult");
|
||||
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
|
||||
assert.equal(chats({ items: [chat] }), true);
|
||||
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
|
||||
const history = createDefinitionValidator("PluginAgentHistoryResult");
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
|
||||
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const MAX_BYTES = 1_048_576;
|
||||
const MAX_CONVERSATIONS = 64;
|
||||
|
||||
// Core-owned AISession documents, outside plugin filesystem/storage roots.
|
||||
// The caller must authenticate provenance before supplying the opaque owner.
|
||||
// Native resume identity, compaction and tool history stay in this repository;
|
||||
// only a separate public projection is returned through the plugin API.
|
||||
class PluginAgentConversationStore {
|
||||
constructor(directory) {
|
||||
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
|
||||
this.directory = directory;
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
#ownerDirectory(owner) {
|
||||
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|
||||
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
|
||||
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
|
||||
}
|
||||
|
||||
#filename(owner, id) {
|
||||
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
|
||||
return path.join(this.#ownerDirectory(owner), `${id}.json`);
|
||||
}
|
||||
|
||||
async #prepare(owner) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
|
||||
const directory = this.#ownerDirectory(owner);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
|
||||
return directory;
|
||||
}
|
||||
|
||||
#validate(record) {
|
||||
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|
||||
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|
||||
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|
||||
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|
||||
|| !Array.isArray(record.messages) || record.messages.length > 200
|
||||
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
|
||||
const text = JSON.stringify(record);
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
|
||||
return text;
|
||||
}
|
||||
|
||||
async #serial(owner, operation) {
|
||||
const key = this.#ownerDirectory(owner);
|
||||
const previous = this.pending.get(key) ?? Promise.resolve();
|
||||
const current = previous.catch(() => {}).then(operation);
|
||||
this.pending.set(key, current);
|
||||
try { return await current; }
|
||||
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
|
||||
}
|
||||
|
||||
async list(owner, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const files = await fs.readdir(directory);
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const id = file.slice(0, -5);
|
||||
if (!file.endsWith(".json") || !UUID.test(id)) continue;
|
||||
if (records.length >= MAX_CONVERSATIONS) break;
|
||||
const record = await this.read(owner, id, assertCurrent);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
await assertCurrent();
|
||||
return records.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async read(owner, id, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const filename = this.#filename(owner, id);
|
||||
await this.#prepare(owner);
|
||||
let file;
|
||||
try {
|
||||
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
|
||||
const { constants } = require("node:fs");
|
||||
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
|
||||
const record = JSON.parse(await file.readFile("utf8"));
|
||||
this.#validate(record);
|
||||
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
|
||||
await assertCurrent();
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") { await assertCurrent(); return null; }
|
||||
throw error;
|
||||
} finally { await file?.close(); }
|
||||
}
|
||||
|
||||
async save(owner, record, assertCurrent) {
|
||||
// Clone/serialize before any await so caller mutation cannot change commit.
|
||||
const text = this.#validate(record);
|
||||
const id = record.conversationRef;
|
||||
return this.#serial(owner, async () => {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const filename = this.#filename(owner, id);
|
||||
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
|
||||
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
|
||||
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
await assertCurrent();
|
||||
await fs.rename(temporary, filename);
|
||||
} finally { await fs.rm(temporary, { force: true }); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAgentConversationStore };
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
|
||||
|
||||
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
|
||||
const current = async () => {};
|
||||
function record() {
|
||||
const conversationRef = randomUUID();
|
||||
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
|
||||
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
|
||||
}
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
|
||||
}
|
||||
|
||||
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record();
|
||||
await store.save(owner("a"), chat, current);
|
||||
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.doesNotMatch(directory, /opaque|same-plugin/);
|
||||
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
|
||||
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
|
||||
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
|
||||
assert.deepEqual(await store.list(owner("b"), current), []);
|
||||
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
|
||||
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
|
||||
});
|
||||
|
||||
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
|
||||
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const next = { ...chat, title: "Must not commit" }; let checks = 0;
|
||||
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
|
||||
});
|
||||
|
||||
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
|
||||
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
|
||||
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
|
||||
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
|
||||
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
|
||||
});
|
||||
|
||||
test("symlinked history roots and files do not become history authority", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
|
||||
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
|
||||
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
|
||||
await fs.rm(filename); await fs.symlink(unrelated, filename);
|
||||
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
|
||||
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
|
||||
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
|
||||
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
|
||||
const key = (target) => JSON.stringify([target.kind, target.id]);
|
||||
const isRequestCancellation = (error, context) => context.signal?.aborted === true
|
||||
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|
||||
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
|
||||
|
||||
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
|
||||
// supplies snapshots after resolving configured backends and choosing targets.
|
||||
// Plugins never supply owner, revision, capability policy or executable adapters.
|
||||
class DelegatedAgentScopes {
|
||||
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
|
||||
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
|
||||
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
|
||||
}
|
||||
this.resolveOwner = resolveOwner;
|
||||
this.assertHostActive = assertHostActive;
|
||||
this.authorizeAction = authorizeAction;
|
||||
this.clock = clock;
|
||||
this.scopes = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async #owner(context) {
|
||||
if (this.closed) throw fail("unavailable");
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.assertHostActive(context);
|
||||
context.signal?.throwIfAborted();
|
||||
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
|
||||
return owner;
|
||||
}
|
||||
|
||||
// The snapshot is host-owned. A public schema must only accept returned handles.
|
||||
async issue(context, { backend, targets, capabilities }) {
|
||||
const owner = await this.#owner(context);
|
||||
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|
||||
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|
||||
|| !Array.isArray(capabilities) || capabilities.length > 128
|
||||
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
|
||||
const captured = new Map();
|
||||
for (const target of targets) {
|
||||
if (!target || !["terminal", "host", "note"].includes(target.kind)
|
||||
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|
||||
|| typeof target.revision !== "string" || !target.revision
|
||||
|| typeof target.owner !== "string" || !target.owner
|
||||
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
|
||||
captured.set(key(target), Object.freeze({ ...target }));
|
||||
}
|
||||
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
|
||||
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
|
||||
this.#assertSnapshots(scope);
|
||||
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
|
||||
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
|
||||
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
|
||||
this.scopes.set(scope.handle, scope);
|
||||
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
|
||||
}
|
||||
|
||||
#assertSnapshots(scope) {
|
||||
// Current-owner/revision checks are synchronous registry lookups, so the
|
||||
// final dispatch check and native call have no asynchronous focus gap.
|
||||
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
|
||||
for (const target of scope.targets.values()) {
|
||||
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
|
||||
}
|
||||
}
|
||||
|
||||
async #resolve(handle, context) {
|
||||
const scope = this.scopes.get(handle);
|
||||
let owner;
|
||||
try { owner = await this.#owner(context); }
|
||||
catch (error) {
|
||||
// Context identity comes from the authenticated host transport. Revoke
|
||||
// only its own captured scope, never a victim referenced by another caller.
|
||||
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
|
||||
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
|
||||
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
|
||||
try {
|
||||
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|
||||
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
} catch (error) {
|
||||
// A failed individual RPC has no authority to revoke another turn. Real
|
||||
// owner/target failure still invalidates the scope, even if a request also
|
||||
// happens to be cancelled at that time.
|
||||
if (!isRequestCancellation(error, context)) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
async begin(handle, context) {
|
||||
const scope = await this.#resolve(handle, context);
|
||||
if (scope.activeTurn) throw fail("alreadyExists");
|
||||
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
|
||||
scope.activeTurn = ticket;
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async assertTurn(ticket, context) {
|
||||
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
|
||||
const scope = await this.#resolve(ticket.scope.handle, context);
|
||||
if (scope.activeTurn !== ticket) throw fail("cancelled");
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
return scope;
|
||||
}
|
||||
|
||||
// Called inside the actual tool dispatcher, after canonical argument/target
|
||||
// resolution. It must not be used merely when displaying the model tool list.
|
||||
async dispatch(ticket, context, action, execute) {
|
||||
const scope = await this.assertTurn(ticket, context);
|
||||
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|
||||
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
|
||||
const targets = action.targets.map((target) => {
|
||||
const captured = target && scope.targets.get(key(target));
|
||||
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
|
||||
return captured;
|
||||
});
|
||||
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
|
||||
// The adapter combines plugin policy and native Agent restrictions once.
|
||||
// No caller can select a process-global permission mode through this API.
|
||||
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
|
||||
await this.assertTurn(ticket, context);
|
||||
this.#assertSnapshots(scope);
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
|
||||
assertCurrent: () => this.assertTurn(ticket, context) }));
|
||||
await this.assertTurn(ticket, context);
|
||||
return result;
|
||||
}
|
||||
|
||||
finish(ticket) {
|
||||
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
|
||||
ticket.scope.activeTurn = null;
|
||||
ticket.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(handle, context) {
|
||||
// Capture before the first await. A prior Stop cannot acquire authority over
|
||||
// a turn that replaces the one the caller intended to stop.
|
||||
const candidate = this.scopes.get(handle);
|
||||
const ticket = candidate?.activeTurn;
|
||||
const scope = await this.#resolve(handle, context);
|
||||
return scope === candidate ? this.finish(ticket) : false;
|
||||
}
|
||||
|
||||
#revoke(scope) {
|
||||
this.finish(scope.activeTurn);
|
||||
this.scopes.delete(scope.handle);
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
releaseAll() {
|
||||
for (const scope of this.scopes.values()) this.#revoke(scope);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
this.releaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DelegatedAgentScopes, isRequestCancellation };
|
||||
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
|
||||
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((finish) => { resolve = finish; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let time = 1; let locked = false; let focus = "terminal-a";
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
|
||||
let privateOwner = "source:opaque-owner-a:com.example.assistant";
|
||||
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
|
||||
let authorize = async () => {};
|
||||
const context = { ...identity, assertActive: async () => {} };
|
||||
const database = {
|
||||
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => privateOwner,
|
||||
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
|
||||
let backendRevision = "configured-1";
|
||||
const currentTargets = new Map([
|
||||
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
|
||||
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
|
||||
]);
|
||||
const backend = { key: "host-configured-claude", revision: backendRevision,
|
||||
isCurrent: (snapshot) => snapshot.revision === backendRevision };
|
||||
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
|
||||
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
|
||||
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
|
||||
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
|
||||
assertHostActive: async () => { if (locked) throw Error("Locked"); },
|
||||
authorizeAction: (...args) => authorize(...args), clock: () => time });
|
||||
return { scopes, context, target, targets: currentTargets, database,
|
||||
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
|
||||
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
|
||||
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
|
||||
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
|
||||
focusTo: (id) => { focus = id; }, focus: () => focus,
|
||||
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
|
||||
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
|
||||
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
|
||||
};
|
||||
}
|
||||
|
||||
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
|
||||
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
|
||||
const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.focusTo("unselected-terminal");
|
||||
let calls = 0;
|
||||
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
|
||||
assert.equal(f.focus(), "unselected-terminal");
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
|
||||
f.runtimeTo("r2");
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
|
||||
f.runtimeTo("r1"); f.replaceSource();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, f.context));
|
||||
});
|
||||
|
||||
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
|
||||
f.authorize(async (_context, owner, action) => {
|
||||
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
|
||||
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise;
|
||||
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
|
||||
release.resolve(); await assert.rejects(result);
|
||||
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
|
||||
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
|
||||
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
|
||||
}
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
|
||||
assert.equal(decisions, 0); assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Deny and revoked source during authorization never dispatch a tool", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let effects = 0;
|
||||
f.authorize(async () => { throw Error("Denied"); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
f.authorize(async () => { f.replaceSource(); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let effects = 0;
|
||||
f.authorize(async (_context, _owner, _action, signal) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
|
||||
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
|
||||
const next = await f.scopes.begin(ref.handle, f.context);
|
||||
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
|
||||
});
|
||||
|
||||
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
|
||||
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
|
||||
});
|
||||
await entered.promise;
|
||||
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
|
||||
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
|
||||
else if (reason === "lock") f.lock();
|
||||
else await f.scopes.stop(ref.handle, f.context);
|
||||
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
|
||||
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
|
||||
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("source replacement detected during owner authentication aborts already-running native work", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
entered.resolve(signal); await release.promise; return "late native result";
|
||||
});
|
||||
const signal = await entered.promise;
|
||||
try {
|
||||
f.replaceSource();
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
} finally { release.resolve(); await assert.rejects(result); }
|
||||
});
|
||||
|
||||
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let blocked = false;
|
||||
const stopContext = { ...f.context, assertActive: async () => {
|
||||
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; assert.equal(f.scopes.finish(first), true);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
release.resolve();
|
||||
assert.equal(await stopping, false);
|
||||
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
|
||||
for (const phase of [1, 5]) {
|
||||
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
|
||||
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
|
||||
if (++checks === phase) { entered.resolve(); await release.promise; }
|
||||
rpc.signal.throwIfAborted();
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; f.scopes.finish(first);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
|
||||
release.resolve();
|
||||
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
|
||||
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
}
|
||||
|
||||
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
|
||||
f.database.getRetainedPluginSource = () => {
|
||||
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
|
||||
return { ...retained(), id: "different-retained-source" };
|
||||
};
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
|
||||
(error) => error !== rpc.signal.reason && error.code === -32007);
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
|
||||
assert.equal(ticket.controller.signal.aborted, false);
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
|
||||
});
|
||||
|
||||
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
|
||||
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
|
||||
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
|
||||
for (let i = 0; i < 8; i++) await f.issue();
|
||||
await assert.rejects(f.issue());
|
||||
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
|
||||
f.scopes.shutdown(); await assert.rejects(f.issue());
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -241,8 +242,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) {
|
||||
@@ -259,7 +262,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();
|
||||
@@ -290,6 +295,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));
|
||||
@@ -323,8 +342,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 {
|
||||
@@ -336,8 +357,10 @@ class PluginFilesystemBroker {
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = this.validateDirectory(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");
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
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");
|
||||
@@ -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");
|
||||
@@ -100,7 +102,7 @@ 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 ?? [])])]
|
||||
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
|
||||
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
|
||||
const database = new PluginDatabase(paths.database);
|
||||
@@ -108,6 +110,8 @@ function createPluginHostService(options) {
|
||||
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) });
|
||||
@@ -165,7 +169,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({
|
||||
@@ -297,6 +302,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);
|
||||
@@ -317,9 +323,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));
|
||||
@@ -462,6 +499,7 @@ function createPluginHostService(options) {
|
||||
interactionService.shutdown();
|
||||
byteStreamService.shutdown();
|
||||
aiContextService.shutdown();
|
||||
agentService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
@@ -501,6 +539,7 @@ function createPluginHostService(options) {
|
||||
networkBroker,
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
agentService,
|
||||
permissionEngine,
|
||||
permissionDecisionHost,
|
||||
quotaManager,
|
||||
@@ -516,6 +555,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),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
@@ -587,6 +590,7 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
credentials,
|
||||
network,
|
||||
ai,
|
||||
agent,
|
||||
selectedContext,
|
||||
ui,
|
||||
vault,
|
||||
|
||||
+19
-12
@@ -115,6 +115,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);
|
||||
@@ -1996,23 +2003,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);
|
||||
@@ -2073,8 +2080,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 });
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.package/
|
||||
dist/
|
||||
*.ncpkg
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
|
||||
The command runtime remembers this sample's current opaque chat handle across
|
||||
modal closes. A later explicit Open releases that exact lease before opening
|
||||
the selected conversation. View pagehide cleanup is best effort; core source,
|
||||
expiry and duplicate-open checks still apply. No handle is stored persistently.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
/* global setImmediate */
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
async function fixture() {
|
||||
const commands = new Map(); const active = new Set(); const calls = []; let sequence = 0; let failure; let start = async () => {};
|
||||
const plugin = (await import("./src/index.js")).default;
|
||||
plugin.activate({ subscriptions: { add() {} }, commands: { registerCommand: (id, handler) => {
|
||||
commands.set(id.split(".").at(-1), handler); return { dispose() {} };
|
||||
} }, agent: {
|
||||
openChat: async () => { await start(); const chatHandle = `owned-${++sequence}`; calls.push(["open", chatHandle]); active.add(chatHandle); return { chatHandle }; },
|
||||
closeChat: async ({ chatHandle }) => {
|
||||
calls.push(["close", chatHandle]);
|
||||
if (failure) throw Object.assign(Error("Synthetic host error"), { code: failure });
|
||||
if (!active.delete(chatHandle)) throw Object.assign(Error("Unavailable owned handle"), { code: "permission_denied" });
|
||||
return { ok: true };
|
||||
},
|
||||
} });
|
||||
return { call: (name, params) => commands.get(name)(params), active, calls,
|
||||
failClose: (code) => { failure = code; }, delayOpen: (fn) => { start = fn; } };
|
||||
}
|
||||
|
||||
test("sample serializes modal replacement and a stale Close cannot forget the replacement lease", async () => {
|
||||
const f = await fixture(); let resume; f.delayOpen(() => new Promise((resolve) => { resume = resolve; }));
|
||||
const first = f.call("open");
|
||||
for (let i = 0; i < 3; i++) await new Promise(setImmediate);
|
||||
const second = f.call("open"); f.delayOpen(async () => {}); resume();
|
||||
const a = await first; const b = await second;
|
||||
assert.deepEqual(f.calls, [["open", a.chatHandle], ["close", a.chatHandle], ["open", b.chatHandle]]);
|
||||
await assert.rejects(f.call("close", { chatHandle: a.chatHandle }), { code: "permission_denied" });
|
||||
const c = await f.call("open");
|
||||
assert.deepEqual([...f.active], [c.chatHandle]);
|
||||
assert.deepEqual(f.calls.slice(-2), [["close", b.chatHandle], ["open", c.chatHandle]]);
|
||||
});
|
||||
|
||||
test("sample recovers revoked owned leases but preserves unknown cleanup failures and activation isolation", async () => {
|
||||
const f = await fixture(); const a = await f.call("open"); f.active.clear();
|
||||
const b = await f.call("open"); assert.notEqual(b.chatHandle, a.chatHandle);
|
||||
f.failClose("unavailable"); const before = f.calls.length;
|
||||
await assert.rejects(f.call("open"), { code: "unavailable" });
|
||||
assert.deepEqual(f.calls.slice(before), [["close", b.chatHandle]], "no replacement Open after an uncertain cleanup failure");
|
||||
const other = await fixture(); await other.call("open"); assert.deepEqual(other.calls, [["open", "owned-1"]]);
|
||||
assert.deepEqual([...f.active], [b.chatHandle], "another activation cannot share the remembered lease");
|
||||
f.failClose(undefined); const c = await f.call("open"); assert.deepEqual([...f.active], [c.chatHandle]);
|
||||
});
|
||||
@@ -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"}}
|
||||
@@ -0,0 +1,31 @@
|
||||
: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; }
|
||||
html, body { height:100%; overflow:hidden; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; height:100%; min-height:0; padding:12px; margin:auto; display:flex; flex-direction:column; gap:8px; }
|
||||
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
|
||||
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:10px; margin:0; background:hsl(var(--muted)); }
|
||||
.setup { flex:0 1 auto; min-height:0; overflow-y:auto; display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:6px 12px; }
|
||||
.setup > div { min-width:0; }
|
||||
.setup .actions, #selection { grid-column:1 / -1; margin:0; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:3px; }
|
||||
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:6px 10px; border-radius:7px; font:14px/1.5 system-ui,sans-serif; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
|
||||
textarea { display:block; resize:vertical; height:64px; min-height:64px; max-height:16vh; }
|
||||
form { flex:none; margin:0; }
|
||||
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:8px; }
|
||||
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:6px 12px; font:14px/1.5 system-ui,sans-serif; font-weight:600; cursor:pointer; }
|
||||
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
|
||||
.response { flex:1 0 190px; min-height:190px; display:grid; grid-template-columns:auto minmax(0,1fr); grid-template-rows:minmax(0,1fr) auto; column-gap:12px; overflow:hidden; }
|
||||
pre { grid-column:1 / -1; min-height:0; overflow-y:auto; overscroll-behavior:contain; white-space:pre-wrap; overflow-wrap:anywhere; font:14px/1.6 system-ui,sans-serif; margin:0; }
|
||||
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); }
|
||||
.response .hint { min-width:0; margin:6px 0 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.hint:empty { display:none; }
|
||||
main > #qa { flex:none; margin:0; }
|
||||
footer { flex:none; margin:0; }
|
||||
@media(max-width:520px) { .setup { grid-template-columns:minmax(0,1fr); } }
|
||||
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
|
||||
@@ -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><p id="qa" class="hint"></p>
|
||||
<section class="card setup"><div><label for="backend">Configured Agent backend</label><select id="backend" disabled></select></div>
|
||||
<div><label for="conversation">Owned conversation</label><select id="conversation"><option value="">New conversation</option></select></div>
|
||||
<div class="actions"><button id="refresh" type="button">Refresh backends</button><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>Actions follow this plugin's Allow / Ask / Deny setting.</footer></main><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,94 @@
|
||||
/* 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');
|
||||
element('usage').textContent = ''; element('activity').textContent = '';
|
||||
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,53 @@
|
||||
"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, preserves expiry drafts and clears status on conversation changes", 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; let history = [];
|
||||
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") return { items: [] };
|
||||
if (op === "history") return { items: history };
|
||||
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);
|
||||
element("usage").textContent = "Previous conversation usage"; element("activity").textContent = "Previous conversation activity";
|
||||
element("open").click(); await settle();
|
||||
assert.equal(element("transcript").textContent, ""); assert.equal(element("usage").textContent, ""); assert.equal(element("activity").textContent, "");
|
||||
history = [{ role: "assistant", text: "Owned synthetic history" }];
|
||||
const owned = dom.window.document.createElement("option"); owned.value = "owned-conversation"; element("conversation").append(owned);
|
||||
element("usage").textContent = "Another conversation usage"; element("activity").textContent = "Another conversation activity";
|
||||
element("conversation").value = owned.value; element("conversation").dispatchEvent(new dom.window.Event("change")); await settle();
|
||||
element("open").click(); await settle();
|
||||
assert.match(element("transcript").textContent, /Owned synthetic history/);
|
||||
assert.equal(element("usage").textContent, ""); assert.equal(element("activity").textContent, "");
|
||||
assert.equal(element("prompt").value, "Keep this synthetic draft"); assert.equal(sends, 1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export default {
|
||||
activate(context) {
|
||||
// The command runtime outlives its modal. Pagehide cleanup can lose view
|
||||
// sender authority, so keep the exact owned lease here until explicit Open
|
||||
// or Close replaces it. Core still validates source, expiry and duplicates.
|
||||
let chatHandle;
|
||||
let changes = Promise.resolve();
|
||||
const changeChat = (operation) => {
|
||||
const result = changes.then(operation); changes = result.catch(() => {}); return result;
|
||||
};
|
||||
const forget = (handle) => { if (chatHandle === handle) chatHandle = undefined; };
|
||||
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 = {}) => {
|
||||
if (name === 'open') return changeChat(async () => {
|
||||
if (chatHandle) {
|
||||
const previous = chatHandle;
|
||||
try { await context.agent.closeChat({ chatHandle: previous }); }
|
||||
catch (error) {
|
||||
// Missing/expired/revoked handles carry no current authority.
|
||||
// Fresh Open must independently authenticate and select again.
|
||||
// Preserve unknown failures for an explicit retry of this lease.
|
||||
if (error?.code !== 'permission_denied') throw error;
|
||||
}
|
||||
forget(previous);
|
||||
}
|
||||
const result = await context.agent.openChat(params); chatHandle = result.chatHandle; return result;
|
||||
});
|
||||
if (name === 'close') return changeChat(async () => {
|
||||
try { const result = await context.agent.closeChat(params); forget(params.chatHandle); return result; }
|
||||
catch (error) { if (error?.code === 'permission_denied') forget(params.chatHandle); throw error; }
|
||||
});
|
||||
return context.agent[operation](params);
|
||||
},
|
||||
));
|
||||
},
|
||||
};
|
||||
Vendored
+3
@@ -494,6 +494,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>;
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ test('AgentRuntime records session state from tool call and result events', asyn
|
||||
|
||||
const sessionStateStore = new SessionStateStore();
|
||||
const runtime = new AgentRuntime({ drivers: [new ToolTurnDriver()], sessionStateStore });
|
||||
const resultNames: Array<string | undefined> = [];
|
||||
runtime.subscribe((event) => { if (event.type === 'tool_result') resultNames.push(event.toolName); });
|
||||
await runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-tool',
|
||||
@@ -135,6 +137,7 @@ test('AgentRuntime records session state from tool call and result events', asyn
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resultNames, ['terminal_execute'], 'listeners receive the same per-turn name resolution as session state');
|
||||
const text = sessionStateStore.toReinjectionText('chat-tool');
|
||||
assert.ok(text?.includes('uptime'));
|
||||
assert.ok(text?.includes('check uptime'));
|
||||
|
||||
@@ -176,6 +176,9 @@ export class AgentRuntime {
|
||||
const meta = toolCallMeta.get(event.toolCallId);
|
||||
const toolName = event.toolName ?? meta?.toolName;
|
||||
if (toolName) {
|
||||
// SDKs such as Claude omit names on results. Publish the same
|
||||
// per-turn correlation used below to downstream event consumers.
|
||||
event = { ...event, toolName };
|
||||
sessionStateStore.updateFromToolResult(
|
||||
chatSessionId,
|
||||
toolName,
|
||||
|
||||
@@ -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,190 @@
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { PluginAgentEvent, 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';
|
||||
import { mapSdkStreamEventToAgentEvents } from '../harness/agentEventAdapter';
|
||||
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||
function fixture(t: TestContext, driver: TurnDriver, prepare = async () => {}, backend: 'catty' | 'claude' = 'catty') {
|
||||
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: () => backend === 'catty' ? ({ config: { id: 'fixture', name: 'Fixture', providerId: 'custom', enabled: true, defaultModel: 'fixture', apiKey: 'FIXTURE_MARKER' }, label: 'Fixture', revision }) : null,
|
||||
prepareModel: prepare, getExternalModel: () => undefined,
|
||||
getExternal: () => backend === 'claude' ? [{ id: 'synthetic-claude', name: 'Synthetic Claude', command: '/synthetic/claude', sdkBackend: 'claude', enabled: true }] : [] });
|
||||
const runtime = createTrustedPluginAgentRuntime({ isActive: () => true, chooseTargets: async () => [], backends, runtime: new AgentRuntime({ drivers: [driver] }) });
|
||||
const session = { conversationRef: crypto.randomUUID(), id: `plugin-agent:${crypto.randomUUID()}`, agentId: backend, title: 'Synthetic', scope: { type: 'global' }, messages: [], createdAt: 1, updatedAt: 1 };
|
||||
const call = (kind: string, payload: unknown, requestId: string = crypto.randomUUID(), runtimeId = 'r1', signal = new AbortController().signal): Promise<PluginAgentHostResult> => runtime.handle({ requestId, kind, payload, runtimeId, actionToken: 'fixture' } as PluginAgentHostRequest, signal);
|
||||
t.after(() => { runtime.shutdown(); if (old) Object.defineProperty(globalThis, 'window', old); else Reflect.deleteProperty(globalThis, 'window'); });
|
||||
const start = async (requestId?: string, signal?: AbortSignal) => {
|
||||
const result = await call('agent.backends', {}); assert.ok('backends' in result);
|
||||
const selected = result.backends.find((item) => item.backend === backend); assert.ok(selected);
|
||||
return call('agent.run', { session, backendHandle: selected.hostHandle, terminalSessions: [], text: 'Synthetic user intent', maxIterations: 12, delegationTag: crypto.randomUUID(), capabilities: [] }, requestId, 'r1', signal);
|
||||
};
|
||||
return { runtime, call, start, modelTo: (value: string) => { revision = value; } };
|
||||
}
|
||||
|
||||
async function readEvents(f: ReturnType<typeof fixture>, started: PluginAgentHostResult) {
|
||||
const events: PluginAgentEvent[] = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const next = await f.call('agent.next', started); assert.ok('event' in next); events.push(next.event);
|
||||
if (next.event.kind === 'end') break;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
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('delegated Claude translated unnamed tool results retain their call capability and final answer', async (t) => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { translateClaudeMessage } = require('../../../electron/bridges/aiBridge/sdk/claudeDriver.cjs');
|
||||
const { createStreamEmitter } = require('../../../electron/bridges/aiBridge/sdk/emit.cjs');
|
||||
let aborted = false;
|
||||
const f = fixture(t, { backend: 'external-sdk', run: async (input, context) => {
|
||||
assert.equal(input.backend, 'external-sdk');
|
||||
const emitter = createStreamEmitter({ sender: {}, requestId: 'synthetic-sdk', safeSend: (_sender: unknown, _channel: string, payload: { event: Record<string, unknown> }) => {
|
||||
for (const event of mapSdkStreamEventToAgentEvents(payload.event, { sessionId: input.chatSessionId, chatSessionId: input.chatSessionId, turnId: context.turnId })) context.emit(event);
|
||||
} });
|
||||
// Translate real Claude message shapes; no tool dispatch,CLI or provider.
|
||||
translateClaudeMessage({ type: 'assistant', message: { content: [
|
||||
{ type: 'tool_use', id: 'synthetic-call-env', name: 'mcp__netcatty__get_environment', input: { private: 'DO_NOT_SERIALIZE_ARGS' } },
|
||||
{ type: 'tool_use', id: 'synthetic-call-terminal', name: 'mcp__netcatty__terminal_execute', input: {} },
|
||||
] } }, emitter);
|
||||
translateClaudeMessage({ type: 'user', message: { content: [
|
||||
{ type: 'tool_result', tool_use_id: 'synthetic-call-terminal', content: 'DO_NOT_SERIALIZE_RESULT' },
|
||||
{ type: 'tool_result', tool_use_id: 'synthetic-call-env', content: 'DO_NOT_SERIALIZE_RESULT' },
|
||||
] } }, emitter);
|
||||
aborted = context.signal.aborted;
|
||||
emitter.text('Synthetic final Claude answer');
|
||||
} }, undefined, 'claude');
|
||||
const events = await readEvents(f, await f.start());
|
||||
assert.equal(aborted, false);
|
||||
assert.ok(events.some((event) => event.kind === 'activity' && event.capabilityId === 'session.environment' && event.state === 'completed'));
|
||||
assert.ok(events.some((event) => event.kind === 'activity' && event.capabilityId === 'terminal.execute' && event.state === 'completed'));
|
||||
assert.ok(events.some((event) => event.kind === 'text' && event.text === 'Synthetic final Claude answer'));
|
||||
assert.deepEqual(events.at(-1), { kind: 'end', reason: 'completed' });
|
||||
assert.doesNotMatch(JSON.stringify(events), /DO_NOT_SERIALIZE|synthetic-call|toolName|toolCallId/);
|
||||
});
|
||||
|
||||
for (const kind of ['orphan', 'unknown-tool', 'previous-turn'] as const) test(`delegated unnamed result correlation rejects ${kind} metadata`, async (t) => {
|
||||
let starts = 0;
|
||||
const f = fixture(t, { backend: 'external-sdk', run: async (_input, context) => {
|
||||
starts++;
|
||||
if (kind === 'unknown-tool' || (kind === 'previous-turn' && starts === 1)) {
|
||||
context.emit({ id: 'call', type: 'tool_call', toolCallId: 'reused-call',
|
||||
toolName: kind === 'unknown-tool' ? 'Bash' : 'get_environment', args: {} } as Parameters<typeof context.emit>[0]);
|
||||
}
|
||||
if (kind === 'previous-turn' && starts === 1) return;
|
||||
context.emit({ id: 'result', type: 'tool_result', toolCallId: 'reused-call', result: 'DO_NOT_SERIALIZE_RESULT' } as Parameters<typeof context.emit>[0]);
|
||||
} }, undefined, 'claude');
|
||||
if (kind === 'previous-turn') {
|
||||
const first = await f.start(); const events = await readEvents(f, first);
|
||||
assert.deepEqual(events.at(-1), { kind: 'end', reason: 'completed' }); await f.call('agent.release', first);
|
||||
}
|
||||
const events = await readEvents(f, await f.start());
|
||||
assert.deepEqual(events.at(-1), { kind: 'end', reason: 'unavailable' });
|
||||
assert.equal(events.some((event) => event.kind === 'activity' && event.state === 'completed'), false);
|
||||
assert.doesNotMatch(JSON.stringify(events), /DO_NOT_SERIALIZE_RESULT|reused-call|Bash/);
|
||||
});
|
||||
|
||||
test('plugin backend listing and current checks never invoke Claude native model discovery or repair', async (t) => {
|
||||
const { getDriver } = createRequire(import.meta.url)('../../../electron/bridges/aiBridge/sdk/index.cjs');
|
||||
let discoveryCalls = 0;
|
||||
t.mock.method(getDriver('claude'), 'listModels', async () => { discoveryCalls++; throw Error('Native discovery must not run'); });
|
||||
const backends = new ConfiguredPluginAgentBackends({ getModel: () => null,
|
||||
getExternal: () => [{ id: 'selected-claude', name: 'Synthetic Claude', command: '/synthetic/official-claude', enabled: true, sdkBackend: 'claude',
|
||||
env: { CLAUDE_CONFIG_DIR: '/synthetic/selected-profile', ANTHROPIC_API_KEY: 'FIXTURE_ONLY_MARKER' } }],
|
||||
getExternalModel: () => 'synthetic-model' });
|
||||
const host = createTrustedPluginAgentRuntime({ isActive: () => true, chooseTargets: async () => [], backends, runtime: new AgentRuntime({ drivers: [] }) });
|
||||
t.after(() => host.shutdown());
|
||||
const request = { requestId: crypto.randomUUID(), runtimeId: 'r1', actionToken: 'fixture', kind: 'agent.backends', payload: {} } as const;
|
||||
const result = await host.handle(request, new AbortController().signal); assert.ok('backends' in result);
|
||||
const selected = result.backends.find((entry) => entry.backend === 'claude')!; assert.equal(selected.status, 'supported');
|
||||
await backends.resolve(selected.hostHandle, 'r1').prepare();
|
||||
const current = await host.handle({ ...request, kind: 'agent.current', payload: { hostHandle: selected.hostHandle } }, new AbortController().signal);
|
||||
assert.deepEqual(current, { current: true }); assert.equal(discoveryCalls, 0);
|
||||
assert.doesNotMatch(JSON.stringify(result), /FIXTURE_ONLY_MARKER|CLAUDE_CONFIG_DIR|selected-profile|official-claude/);
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
test('Stop then Send waits for the actual runtime driver to unwind before starting replacement B', async (t) => {
|
||||
const resume = deferred(); t.after(resume.resolve); let starts = 0;
|
||||
const f = fixture(t, { backend: 'catty', run: async (_input, context) => {
|
||||
if (++starts === 1) await resume.promise;
|
||||
context.emit({ id: 'delta', type: 'model_delta', text: starts === 1 ? 'LATE_A' : 'Replacement B' } as Parameters<typeof context.emit>[0]);
|
||||
} });
|
||||
const first = await f.start(); assert.ok('hostHandle' in first);
|
||||
await f.call('agent.release', first);
|
||||
let published = false; const pending = f.start().then((value) => { published = true; return value; });
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(published, false, 'replacement handle must not precede runtime availability');
|
||||
assert.equal(starts, 1); resume.resolve();
|
||||
const second = await pending; assert.ok('hostHandle' in second);
|
||||
const next = await f.call('agent.next', second); assert.ok('event' in next);
|
||||
assert.deepEqual(next.event, { kind: 'text', text: 'Replacement B' }); assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
for (const change of ['cancel', 'release', 'configuration'] as const) test(`replacement waiting for old runtime revalidates ${change} before starting`, async (t) => {
|
||||
const resume = deferred(); t.after(resume.resolve); let starts = 0;
|
||||
const f = fixture(t, { backend: 'catty', run: async () => { starts++; await resume.promise; } });
|
||||
const first = await f.start(); await f.call('agent.release', first);
|
||||
const caller = new AbortController(); const pending = f.start(undefined, caller.signal); const rejected = assert.rejects(pending);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
if (change === 'cancel') caller.abort();
|
||||
else if (change === 'release') f.runtime.releaseRuntime('r1');
|
||||
else f.modelTo('2');
|
||||
resume.resolve(); await rejected; assert.equal(starts, 1);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
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');
|
||||
// Stop acknowledges cancellation before an asynchronous driver has
|
||||
// necessarily unwound. Reuse the runtime's existing session barrier.
|
||||
await runtime.waitForActiveTurn(payload.session.id);
|
||||
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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -70,6 +70,7 @@
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
|
||||
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
|
||||
"test:plugin-global-agent-layout": "electron scripts/plugin-global-agent-layout.live.test.cjs",
|
||||
"test:xterm-image-resize": "electron scripts/xterm-image-resize.live.test.cjs",
|
||||
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
|
||||
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -488,7 +488,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -524,6 +524,7 @@ export type PermissionRequest = {
|
||||
resources?: Array<PermissionResource>;
|
||||
resourceKinds?: Array<PermissionResourceKind>;
|
||||
displayContext?: {
|
||||
action?: PluginAgentActionPreview;
|
||||
modelLabel?: string;
|
||||
selections?: Array<{
|
||||
title: string;
|
||||
@@ -556,6 +557,252 @@ export type PermissionSet = {
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginAgentActionPreview = {
|
||||
capabilityId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentBackend = {
|
||||
backendRef: string;
|
||||
label: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
status: "supported" | "unsupported" | "unavailable";
|
||||
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
|
||||
};
|
||||
|
||||
export type PluginAgentBackendKind = "catty" | "claude" | "codex" | "copilot" | "cursor" | "codebuddy" | "opencode" | "grok" | "mimo";
|
||||
|
||||
export type PluginAgentBackendsResult = {
|
||||
items: Array<PluginAgentBackend>;
|
||||
};
|
||||
|
||||
export type PluginAgentCapabilities = ["session.environment","terminal.execute","terminal.start","terminal.poll","terminal.stop","harness.terminal.read_context"];
|
||||
|
||||
export type PluginAgentChatRef = {
|
||||
chatHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentChatResult = {
|
||||
chatHandle: string;
|
||||
conversationRef: string;
|
||||
backendLabel: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentChatsResult = {
|
||||
items: Array<PluginAgentChatSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentChatSummary = {
|
||||
conversationRef: string;
|
||||
title: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentEvent = ({
|
||||
kind: "text";
|
||||
text: string;
|
||||
}) | ({
|
||||
kind: "activity";
|
||||
capabilityId: string;
|
||||
state: "started" | "completed" | "denied";
|
||||
}) | ({
|
||||
kind: "usage";
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}) | ({
|
||||
kind: "end";
|
||||
reason: "completed" | "cancelled" | "unavailable" | "budget-exceeded";
|
||||
});
|
||||
|
||||
export type PluginAgentFeature = "ai.agent-sessions";
|
||||
|
||||
export type PluginAgentHistoryResult = {
|
||||
items: Array<PluginAgentMessage>;
|
||||
};
|
||||
|
||||
export type 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;
|
||||
};
|
||||
|
||||
export type PluginAgentOpenParams = {
|
||||
backendRef: string;
|
||||
selectionHandle: string;
|
||||
conversationRef?: string;
|
||||
};
|
||||
|
||||
export type PluginAgentSendParams = {
|
||||
chatHandle: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetsResult = {
|
||||
selectionHandle: string;
|
||||
expiresAt: number;
|
||||
items: Array<PluginAgentTargetSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetSummary = {
|
||||
title: string;
|
||||
kind: "terminal";
|
||||
};
|
||||
|
||||
export type PluginAgentTurnRef = {
|
||||
turnHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTurnResult = {
|
||||
turnHandle: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAIBooleanResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
@@ -841,7 +1088,7 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type PluginSelectedContextContent = {
|
||||
handle: string;
|
||||
|
||||
@@ -55,6 +55,8 @@ export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MA
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
|
||||
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
|
||||
export const PLUGIN_AGENT_SESSIONS_FEATURE = "ai.agent-sessions" as const satisfies import("./generated/plugin-contract.js").PluginAgentFeature;
|
||||
export const PLUGIN_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;
|
||||
|
||||
@@ -686,6 +686,19 @@ export interface PluginSelectedContextClient {
|
||||
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
|
||||
}
|
||||
|
||||
/** Configurations, targets and native resume IDs stay in the trusted host. */
|
||||
export interface PluginAgentClient {
|
||||
listBackends(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentBackendsResult>;
|
||||
selectTargets(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentTargetsResult>;
|
||||
listChats(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatsResult>;
|
||||
openChat(params: import("@netcatty/plugin-contract").PluginAgentOpenParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatResult>;
|
||||
send(params: import("@netcatty/plugin-contract").PluginAgentSendParams): Promise<import("@netcatty/plugin-contract").PluginAgentTurnResult>;
|
||||
read(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAgentEvent>;
|
||||
history(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAgentHistoryResult>;
|
||||
stop(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
closeChat(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
}
|
||||
|
||||
export interface PluginByteConnection extends Disposable {
|
||||
readonly endpoint: string;
|
||||
readonly readable: PluginReadableByteStream;
|
||||
@@ -786,6 +799,7 @@ export interface PluginContext {
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly ai: PluginAIClient;
|
||||
readonly agent: PluginAgentClient;
|
||||
readonly selectedContext: PluginSelectedContextClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, clearTimeout, setTimeout, console */
|
||||
|
||||
// Render only the sample's HTML/CSS with synthetic text. No plugin, host bridge,
|
||||
// provider, account configuration or Agent runtime is started by this test.
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("global Agent sample preserves transcript and composer space", {
|
||||
skip: "run npm run test:plugin-global-agent-layout in the scheduled native QA environment",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, session } = require("electron");
|
||||
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const sample = path.join(__dirname, "../examples/plugins/global-agent");
|
||||
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("agent-layout-test")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let win;
|
||||
const finish = (code) => {
|
||||
clearTimeout(watchdog);
|
||||
if (win && !win.isDestroyed()) win.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
const watchdog = setTimeout(() => { console.error("AGENT_LAYOUT_FAIL timeout"); finish(1); }, 15_000);
|
||||
void app.whenReady().then(async () => {
|
||||
// Inline the real stylesheet and omit panel.js: even a broken layout has no
|
||||
// path to execute plugin commands or initiate model/credential requests.
|
||||
const html = fs.readFileSync(path.join(sample, "panel.html"), "utf8")
|
||||
.replace('<link rel="stylesheet" href="panel.css">', `<style>${fs.readFileSync(path.join(sample, "panel.css"), "utf8")}</style>`)
|
||||
.replace('<script src="panel.js"></script>', "")
|
||||
.replace("<head>", '<head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'">');
|
||||
const isolatedSession = session.fromPartition("agent-layout-synthetic");
|
||||
isolatedSession.webRequest.onBeforeRequest({ urls: ["http://*/*", "https://*/*"] }, (_details, callback) => callback({ cancel: true }));
|
||||
win = new BrowserWindow({ show: false, useContentSize: true, width: 798, height: 548,
|
||||
webPreferences: { session: isolatedSession, sandbox: true, contextIsolation: true, nodeIntegration: false } });
|
||||
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
for (const qa of [false, true]) {
|
||||
const actual = await win.webContents.executeJavaScript(`(() => {
|
||||
const element = id => document.getElementById(id);
|
||||
element('qa').textContent = ${JSON.stringify(qa ? "Isolated deterministic fixture · no real model inference or paid API" : "")};
|
||||
element('transcript').textContent = 'assistant: ' + 'Synthetic owned conversation. '.repeat(100);
|
||||
element('usage').textContent = 'Input 12 · Output 6';
|
||||
element('activity').textContent = 'session.environment';
|
||||
element('status').textContent = 'Complete';
|
||||
element('stop').disabled = false;
|
||||
element('backend').innerHTML = '<option>Synthetic configured Catty</option>';
|
||||
element('conversation').innerHTML = '<option>Owned synthetic conversation</option>';
|
||||
const bounds = node => { const r = node.getBoundingClientRect(); return { top:r.top, bottom:r.bottom, height:r.height, width:r.width }; };
|
||||
const transcript = element('transcript');
|
||||
const setup = document.querySelector('.setup') || element('backend').closest('section');
|
||||
return { width:innerWidth, height:innerHeight, transcript:bounds(transcript),
|
||||
transcriptClient:transcript.clientHeight, transcriptScroll:transcript.scrollHeight,
|
||||
response:bounds(transcript.closest('section')), compose:bounds(element('compose')), stop:bounds(element('stop')),
|
||||
setup:bounds(setup), setupOverflow:getComputedStyle(setup).overflowY };
|
||||
})()`);
|
||||
const detail = JSON.stringify({ qa, ...actual });
|
||||
assert.equal(actual.width, 798, detail); assert.equal(actual.height, 548, detail);
|
||||
assert.ok(actual.response.height >= 180, `usable response card: ${detail}`);
|
||||
assert.ok(actual.transcriptClient >= 120, `visible conversation text: ${detail}`);
|
||||
assert.ok(actual.transcriptScroll > actual.transcriptClient, `transcript scrolls independently: ${detail}`);
|
||||
assert.ok(actual.compose.top >= actual.response.bottom - 1, `composer follows transcript: ${detail}`);
|
||||
assert.ok(actual.stop.bottom <= actual.height && actual.stop.height >= 30, `mouse Stop stays visible: ${detail}`);
|
||||
assert.ok(actual.setup.height >= 100 && actual.setupOverflow === "auto", `configuration stays reachable: ${detail}`);
|
||||
console.log(`AGENT_LAYOUT_PASS ${detail}`);
|
||||
}
|
||||
finish(0);
|
||||
}).catch((error) => { console.error("AGENT_LAYOUT_FAIL", error); finish(1); });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user