diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 6ab946a89..364fa9d55 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -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} /> + {/* Global "quick add / edit snippet" modal, triggered by the diff --git a/application/state/usePluginAgentHost.ts b/application/state/usePluginAgentHost.ts new file mode 100644 index 000000000..6cc511683 --- /dev/null +++ b/application/state/usePluginAgentHost.ts @@ -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(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(); + 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 }; +} diff --git a/components/PluginGlobalAgentHost.test.tsx b/components/PluginGlobalAgentHost.test.tsx new file mode 100644 index 000000000..041c7baff --- /dev/null +++ b/components/PluginGlobalAgentHost.test.tsx @@ -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('
', { pretendToBeVisual: true, url: 'http://localhost' }); + const previous = new Map(); + 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 | 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((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 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 }; + 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, 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>); + 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()); + 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('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((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 }); + } +}); diff --git a/components/plugins/PluginAgentHost.tsx b/components/plugins/PluginAgentHost.tsx new file mode 100644 index 000000000..9d32ffe89 --- /dev/null +++ b/components/plugins/PluginAgentHost.tsx @@ -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([]); + return { if (!open) choice?.cancel(); }}> + setSelected([])}> + Choose terminals for this Agent conversation + 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. +
+ {choice?.items.map((item) => )} + {choice?.items.length === 0 &&

No connected terminals. You can continue without targets.

} +
+ + +
+
; +} diff --git a/components/plugins/PluginPermissionHost.tsx b/components/plugins/PluginPermissionHost.tsx index f1dfd8cc7..13171e433 100644 --- a/components/plugins/PluginPermissionHost.tsx +++ b/components/plugins/PluginPermissionHost.tsx @@ -20,6 +20,10 @@ export function PluginPermissionHost() {

{description.description}

{prompt.displayContext?.modelLabel &&

{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}

} + {prompt.displayContext?.action &&
+

{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}

+
{prompt.displayContext.action.text}
+
} {(prompt.displayContext?.selections ?? []).map((item, index) =>

{item.title} · {item.scope}

)} diff --git a/docs/plugin-platform/global-agent-wip.md b/docs/plugin-platform/global-agent-wip.md index ce640dd83..acfdf3121 100644 --- a/docs/plugin-platform/global-agent-wip.md +++ b/docs/plugin-platform/global-agent-wip.md @@ -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. diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index 3be3424f1..84de8eab3 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -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)), diff --git a/electron/bridges/aiBridge.test.cjs b/electron/bridges/aiBridge.test.cjs index 108639294..12b7bf585 100644 --- a/electron/bridges/aiBridge.test.cjs +++ b/electron/bridges/aiBridge.test.cjs @@ -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" }); diff --git a/electron/bridges/aiBridge/providerHandlers.cjs b/electron/bridges/aiBridge/providerHandlers.cjs index 652d158c5..bfb4289f9 100644 --- a/electron/bridges/aiBridge/providerHandlers.cjs +++ b/electron/bridges/aiBridge/providerHandlers.cjs @@ -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); } } }); } diff --git a/electron/bridges/aiBridge/sdk/claudeDriver.cjs b/electron/bridges/aiBridge/sdk/claudeDriver.cjs index ce361e5fd..b942d1b2c 100644 --- a/electron/bridges/aiBridge/sdk/claudeDriver.cjs +++ b/electron/bridges/aiBridge/sdk/claudeDriver.cjs @@ -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; } diff --git a/electron/bridges/aiBridge/sdk/claudeDriver.test.cjs b/electron/bridges/aiBridge/sdk/claudeDriver.test.cjs index 381d3dfe9..313a694c4 100644 --- a/electron/bridges/aiBridge/sdk/claudeDriver.test.cjs +++ b/electron/bridges/aiBridge/sdk/claudeDriver.test.cjs @@ -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); diff --git a/electron/bridges/mcpServerBridge.cjs b/electron/bridges/mcpServerBridge.cjs index 26d27c079..4ec14ba49 100644 --- a/electron/bridges/mcpServerBridge.cjs +++ b/electron/bridges/mcpServerBridge.cjs @@ -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, diff --git a/electron/bridges/mcpServerBridge.pluginDelegation.test.cjs b/electron/bridges/mcpServerBridge.pluginDelegation.test.cjs index 20ae67889..f262689b6 100644 --- a/electron/bridges/mcpServerBridge.pluginDelegation.test.cjs +++ b/electron/bridges/mcpServerBridge.pluginDelegation.test.cjs @@ -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); +}); diff --git a/electron/capabilities/catalog/harness.cjs b/electron/capabilities/catalog/harness.cjs index 13a019fbf..9e872d692 100644 --- a/electron/capabilities/catalog/harness.cjs +++ b/electron/capabilities/catalog/harness.cjs @@ -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.", diff --git a/electron/plugins/agent/agentService.cjs b/electron/plugins/agent/agentService.cjs new file mode 100644 index 000000000..dc6986416 --- /dev/null +++ b/electron/plugins/agent/agentService.cjs @@ -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 }; diff --git a/electron/plugins/agent/agentService.test.cjs b/electron/plugins/agent/agentService.test.cjs new file mode 100644 index 000000000..80128454a --- /dev/null +++ b/electron/plugins/agent/agentService.test.cjs @@ -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); +}); diff --git a/electron/plugins/agent/delegatedScope.cjs b/electron/plugins/agent/delegatedScope.cjs index 8c7dec0ae..46bb4a59d 100644 --- a/electron/plugins/agent/delegatedScope.cjs +++ b/electron/plugins/agent/delegatedScope.cjs @@ -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 }; diff --git a/electron/plugins/agent/mcpDelegation.cjs b/electron/plugins/agent/mcpDelegation.cjs index b657b5b7c..77d03c108 100644 --- a/electron/plugins/agent/mcpDelegation.cjs +++ b/electron/plugins/agent/mcpDelegation.cjs @@ -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); } diff --git a/electron/plugins/agent/mcpDelegation.test.cjs b/electron/plugins/agent/mcpDelegation.test.cjs index 1164a8ea2..ddb85c267 100644 --- a/electron/plugins/agent/mcpDelegation.test.cjs +++ b/electron/plugins/agent/mcpDelegation.test.cjs @@ -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); +}); diff --git a/electron/plugins/ai/sourceBinding.cjs b/electron/plugins/ai/sourceBinding.cjs index a12b69451..19f1b7476 100644 --- a/electron/plugins/ai/sourceBinding.cjs +++ b/electron/plugins/ai/sourceBinding.cjs @@ -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); diff --git a/electron/plugins/ai/sourceBinding.test.cjs b/electron/plugins/ai/sourceBinding.test.cjs index be7413ffa..6a21dc6fb 100644 --- a/electron/plugins/ai/sourceBinding.test.cjs +++ b/electron/plugins/ai/sourceBinding.test.cjs @@ -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)); +}); diff --git a/electron/plugins/ai/trustedHostAdapter.cjs b/electron/plugins/ai/trustedHostAdapter.cjs index 80e26bec0..39df0ba26 100644 --- a/electron/plugins/ai/trustedHostAdapter.cjs +++ b/electron/plugins/ai/trustedHostAdapter.cjs @@ -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"); diff --git a/electron/plugins/filesystemBroker.cjs b/electron/plugins/filesystemBroker.cjs index 36960fca9..343217f0a 100644 --- a/electron/plugins/filesystemBroker.cjs +++ b/electron/plugins/filesystemBroker.cjs @@ -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) { diff --git a/electron/plugins/filesystemBroker.test.cjs b/electron/plugins/filesystemBroker.test.cjs index 0a9f2a367..b66ba37e4 100644 --- a/electron/plugins/filesystemBroker.test.cjs +++ b/electron/plugins/filesystemBroker.test.cjs @@ -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}`; diff --git a/electron/plugins/generated/plugin-contract.schema.json b/electron/plugins/generated/plugin-contract.schema.json index 05da0eb2e..8ca4b13cb 100644 --- a/electron/plugins/generated/plugin-contract.schema.json +++ b/electron/plugins/generated/plugin-contract.schema.json @@ -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 + } + } } } } diff --git a/electron/plugins/globalAgent.test.cjs b/electron/plugins/globalAgent.test.cjs index d89a528fa..caff31d19 100644 --- a/electron/plugins/globalAgent.test.cjs +++ b/electron/plugins/globalAgent.test.cjs @@ -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"); diff --git a/electron/plugins/hostService.cjs b/electron/plugins/hostService.cjs index 172a96b96..ec87cbda3 100644 --- a/electron/plugins/hostService.cjs +++ b/electron/plugins/hostService.cjs @@ -32,6 +32,8 @@ const { PluginNetworkBroker } = require("./networkBroker.cjs"); const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs"); const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs"); const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs"); +const { PluginAgentService, PLUGIN_AGENT_FEATURE } = require("./agent/agentService.cjs"); +const { PluginAgentConversationStore } = require("./agent/conversationStore.cjs"); const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs"); const { PluginHostVaultService } = require("./hostVaultService.cjs"); const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs"); @@ -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(); diff --git a/electron/plugins/permissionDecisionHost.cjs b/electron/plugins/permissionDecisionHost.cjs index 341c5fe3d..7b55e3cb5 100644 --- a/electron/plugins/permissionDecisionHost.cjs +++ b/electron/plugins/permissionDecisionHost.cjs @@ -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), })), diff --git a/electron/plugins/runtime/runtimePeer.mjs b/electron/plugins/runtime/runtimePeer.mjs index 8940396f9..5f4fa7213 100644 --- a/electron/plugins/runtime/runtimePeer.mjs +++ b/electron/plugins/runtime/runtimePeer.mjs @@ -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, diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 1354d9230..fc1379225 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -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 }); diff --git a/examples/plugins/global-agent/.gitignore b/examples/plugins/global-agent/.gitignore new file mode 100644 index 000000000..d90562c97 --- /dev/null +++ b/examples/plugins/global-agent/.gitignore @@ -0,0 +1,3 @@ +.package/ +dist/ +*.ncpkg diff --git a/examples/plugins/global-agent/README.md b/examples/plugins/global-agent/README.md new file mode 100644 index 000000000..9448fc7d6 --- /dev/null +++ b/examples/plugins/global-agent/README.md @@ -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. diff --git a/examples/plugins/global-agent/build.mjs b/examples/plugins/global-agent/build.mjs new file mode 100644 index 000000000..615dd05e5 --- /dev/null +++ b/examples/plugins/global-agent/build.mjs @@ -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)); +} diff --git a/examples/plugins/global-agent/netcatty.plugin.json b/examples/plugins/global-agent/netcatty.plugin.json new file mode 100644 index 000000000..17167525c --- /dev/null +++ b/examples/plugins/global-agent/netcatty.plugin.json @@ -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" }] + } +} diff --git a/examples/plugins/global-agent/package.json b/examples/plugins/global-agent/package.json new file mode 100644 index 000000000..fd3dcca23 --- /dev/null +++ b/examples/plugins/global-agent/package.json @@ -0,0 +1 @@ +{"name":"@netcatty/example-global-agent-plugin","version":"0.1.0","private":true,"type":"module","scripts":{"build":"node build.mjs"}} diff --git a/examples/plugins/global-agent/panel.css b/examples/plugins/global-agent/panel.css new file mode 100644 index 000000000..d9225baf3 --- /dev/null +++ b/examples/plugins/global-agent/panel.css @@ -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; } } diff --git a/examples/plugins/global-agent/panel.html b/examples/plugins/global-agent/panel.html new file mode 100644 index 000000000..7398440fe --- /dev/null +++ b/examples/plugins/global-agent/panel.html @@ -0,0 +1,8 @@ +Global Agent Chat +
AGENT PLATFORM EXAMPLE

Global Agent Chat

A conversation independent of the active terminal or workspace.

+
+ +

No targets selected yet.

+

+
Loading configured backends…
+
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.
diff --git a/examples/plugins/global-agent/panel.js b/examples/plugins/global-agent/panel.js new file mode 100644 index 000000000..856a22ac4 --- /dev/null +++ b/examples/plugins/global-agent/panel.js @@ -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(); diff --git a/examples/plugins/global-agent/panel.test.cjs b/examples/plugins/global-agent/panel.test.cjs new file mode 100644 index 000000000..7d9d40373 --- /dev/null +++ b/examples/plugins/global-agent/panel.test.cjs @@ -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); +}); diff --git a/examples/plugins/global-agent/src/index.js b/examples/plugins/global-agent/src/index.js new file mode 100644 index 000000000..ad96d805d --- /dev/null +++ b/examples/plugins/global-agent/src/index.js @@ -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), + )); + }, +}; diff --git a/global.d.ts b/global.d.ts index 850796948..851d663a3 100644 --- a/global.d.ts +++ b/global.d.ts @@ -480,6 +480,9 @@ declare global { getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise; installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise; setPluginAIHostReady?(ready: boolean): Promise; + setPluginAgentHostReady?(ready: boolean): Promise; + respondPluginAgentHost?(response: import("@netcatty/plugin-contract").PluginAgentHostResponse): Promise; + onPluginAgentHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAgentHostRequest) => void): () => void; respondPluginAIHost?(response: import("@netcatty/plugin-contract").PluginAIHostResponse): Promise; onPluginAIHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAIHostRequest) => void): () => void; setPluginInteractionReady?(ready: boolean): Promise; diff --git a/infrastructure/ai/aiChatStreamingSupport.ts b/infrastructure/ai/aiChatStreamingSupport.ts index 16462087a..659e1db8f 100644 --- a/infrastructure/ai/aiChatStreamingSupport.ts +++ b/infrastructure/ai/aiChatStreamingSupport.ts @@ -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; - aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>; + aiCattyCancelExec?(chatSessionId: string, delegationTag?: string): Promise; + aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean, delegationTag?: string): Promise<{ ok: boolean; error?: string }>; aiMcpSyncPermissionGrants?(grants: Array>): Promise<{ ok: boolean; count?: number; error?: string }>; aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>; aiSdkAgentSteer?: ( diff --git a/infrastructure/ai/cattyAgent/systemPrompt.ts b/infrastructure/ai/cattyAgent/systemPrompt.ts index cd3557dd8..cad3ae10f 100644 --- a/infrastructure/ai/cattyAgent/systemPrompt.ts +++ b/infrastructure/ai/cattyAgent/systemPrompt.ts @@ -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.`; } } diff --git a/infrastructure/ai/harness/capabilityTools.ts b/infrastructure/ai/harness/capabilityTools.ts index bdbd98ca3..1f38afdbe 100644 --- a/infrastructure/ai/harness/capabilityTools.ts +++ b/infrastructure/ai/harness/capabilityTools.ts @@ -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> = {}; const toolsContext: Record = {}; - 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; } diff --git a/infrastructure/ai/harness/cattyRuntimeContext.ts b/infrastructure/ai/harness/cattyRuntimeContext.ts index df372add4..43950d1a8 100644 --- a/infrastructure/ai/harness/cattyRuntimeContext.ts +++ b/infrastructure/ai/harness/cattyRuntimeContext.ts @@ -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().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, diff --git a/infrastructure/ai/harness/generated/globalAgentToolSpecs.json b/infrastructure/ai/harness/generated/globalAgentToolSpecs.json index 6bdd97c77..838335c72 100644 --- a/infrastructure/ai/harness/generated/globalAgentToolSpecs.json +++ b/infrastructure/ai/harness/generated/globalAgentToolSpecs.json @@ -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" } ] diff --git a/infrastructure/ai/harness/promptContextSnapshot.ts b/infrastructure/ai/harness/promptContextSnapshot.ts index 5163d9269..8f253f446 100644 --- a/infrastructure/ai/harness/promptContextSnapshot.ts +++ b/infrastructure/ai/harness/promptContextSnapshot.ts @@ -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, diff --git a/infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts b/infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts index 8d59a59cb..331028247 100644 --- a/infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts +++ b/infrastructure/ai/harness/turnDrivers/cattyStreamProcessor.ts @@ -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)) { diff --git a/infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts b/infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts index debcd15a8..f3aadafce 100644 --- a/infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts +++ b/infrastructure/ai/harness/turnDrivers/cattyTurnDriver.ts @@ -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); diff --git a/infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts b/infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts index db2c8d974..b1f9616cc 100644 --- a/infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts +++ b/infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.ts @@ -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) { diff --git a/infrastructure/ai/harness/turnDrivers/types.ts b/infrastructure/ai/harness/turnDrivers/types.ts index 4bef45354..5fb1a0d18 100644 --- a/infrastructure/ai/harness/turnDrivers/types.ts +++ b/infrastructure/ai/harness/turnDrivers/types.ts @@ -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 }>; diff --git a/infrastructure/ai/pluginAgent/configuredBackends.test.ts b/infrastructure/ai/pluginAgent/configuredBackends.test.ts new file mode 100644 index 000000000..d92cd7aed --- /dev/null +++ b/infrastructure/ai/pluginAgent/configuredBackends.test.ts @@ -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(); 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(); +}); diff --git a/infrastructure/ai/pluginAgent/configuredBackends.ts b/infrastructure/ai/pluginAgent/configuredBackends.ts new file mode 100644 index 000000000..8d3b5c46f --- /dev/null +++ b/infrastructure/ai/pluginAgent/configuredBackends.ts @@ -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; + +// 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(STORAGE_KEY_AI_PROVIDERS) ?? []; + const providerMap = localStorageAdapter.read>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}; + const modelMap = localStorageAdapter.read>(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(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; +} + +// 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(); + constructor(private readonly options: { + getModel?: () => Model; + prepareModel?: (revision: string) => Promise; + 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(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []))(); } + private externalModel(id: string) { return (this.options.getExternalModel ?? ((key) => + localStorageAdapter.read>(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, + 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(); } +} diff --git a/infrastructure/ai/pluginAgent/trustedAgentRuntime.test.ts b/infrastructure/ai/pluginAgent/trustedAgentRuntime.test.ts new file mode 100644 index 000000000..4386ba21a --- /dev/null +++ b/infrastructure/ai/pluginAgent/trustedAgentRuntime.test.ts @@ -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((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 => 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[0]); + context.emit({ id: 'tool', type: 'tool_call', toolName: 'terminal_execute', toolCallId: 't1', args: { command: 'DO_NOT_SERIALIZE_TOOL_ARGS' } } as Parameters[0]); + context.emit({ id: 'usage', type: 'usage', promptTokens: 12, completionTokens: 6 } as Parameters[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[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[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' }); +}); diff --git a/infrastructure/ai/pluginAgent/trustedAgentRuntime.ts b/infrastructure/ai/pluginAgent/trustedAgentRuntime.ts new file mode 100644 index 000000000..05f91f13e --- /dev/null +++ b/infrastructure/ai/pluginAgent/trustedAgentRuntime.ts @@ -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(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; + 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; + backends?: ConfiguredPluginAgentBackends; + runtime?: AgentRuntime; +}) { + const backends = options.backends ?? new ConfiguredPluginAgentBackends(); + const runtime = options.runtime ?? getAgentRuntime(); + const turns = new Map(); + const sessionOwners = new Map(); + 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 { + 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, 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, 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((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); } + }); + }, + }; +} diff --git a/infrastructure/ai/sdk/providers.ts b/infrastructure/ai/sdk/providers.ts index 4bfbc7e96..a86b69ad6 100644 --- a/infrastructure/ai/sdk/providers.ts +++ b/infrastructure/ai/sdk/providers.ts @@ -44,6 +44,10 @@ interface BridgeAPI { headers: Record, 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, diff --git a/package-lock.json b/package-lock.json index b3ef487f3..0b163ad06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 diff --git a/package.json b/package.json index 18ad93308..ba7ff0a85 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/plugin-contract/schema/plugin-contract.schema.json b/packages/plugin-contract/schema/plugin-contract.schema.json index 97b3048ee..94094dc8b 100644 --- a/packages/plugin-contract/schema/plugin-contract.schema.json +++ b/packages/plugin-contract/schema/plugin-contract.schema.json @@ -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 + } + } } } } diff --git a/packages/plugin-contract/src/generated/plugin-contract.ts b/packages/plugin-contract/src/generated/plugin-contract.ts index 6205935c8..4f0f1bed1 100644 --- a/packages/plugin-contract/src/generated/plugin-contract.ts +++ b/packages/plugin-contract/src/generated/plugin-contract.ts @@ -522,6 +522,7 @@ export type PermissionRequest = { resources?: Array; resourceKinds?: Array; displayContext?: { + action?: PluginAgentActionPreview; modelLabel?: string; selections?: Array<{ title: string; @@ -554,6 +555,11 @@ export type PermissionSet = { optional?: Array; }; +export type PluginAgentActionPreview = { + capabilityId: string; + text: string; +}; + export type PluginAgentBackend = { backendRef: string; label: string; @@ -568,6 +574,8 @@ export type PluginAgentBackendsResult = { items: Array; }; +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; }; +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; + 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; +}) | ({ + targets: Array; +}) | ({ + 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; + 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; diff --git a/packages/plugin-contract/src/index.ts b/packages/plugin-contract/src/index.ts index 515b6a3b9..af5f4c55a 100644 --- a/packages/plugin-contract/src/index.ts +++ b/packages/plugin-contract/src/index.ts @@ -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; diff --git a/scripts/plugin-global-agent-provider-fixture.mjs b/scripts/plugin-global-agent-provider-fixture.mjs new file mode 100644 index 000000000..7c784a77d --- /dev/null +++ b/scripts/plugin-global-agent-provider-fixture.mjs @@ -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()); +}