feat(plugin-agent): wire core global conversations and minimal Chat sample

This commit is contained in:
陈奇 committed 2026-10-27 08:49:54 +00:00
1 parent ea619555ac
commit f715a0764d
62 files changed
+3634 -54

No files matched your search

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