Global Agent Chat
A conversation independent of the active terminal or workspace.
No targets selected yet.
{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}
+ {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 @@ +A conversation independent of the active terminal or workspace.
No targets selected yet.