From b54d185df2ebc0360745315d4513a72bdb10a8d0 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 05:19:13 -0700 Subject: [PATCH 1/8] fix(coding-agent): retain root kill cleanup ownership --- .../src/modes/daemon/daemon-supervisor.ts | 45 +++++++++--- .../test/daemon-supervisor-monitor.test.ts | 72 +++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 92997edf4..3069dd4af 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -1906,11 +1906,16 @@ export class DaemonSupervisor { return await forward(); } this.persistWorkerStopTombstone(match.worker, true); + const releaseStopOwnership = this.acquireWorkerStopOwnership(match.worker); let response: DaemonResponse; try { response = await this.forwardToWorker(match.worker, resolvedCommand); } finally { - await this.stopWorker(match.worker, true, false, true); + try { + await this.stopWorker(match.worker, true, false, true); + } finally { + releaseStopOwnership(); + } } return response; } finally { @@ -4242,9 +4247,14 @@ export class DaemonSupervisor { !this.shuttingDown ) { worker.intentionalStop = true; - this.workers.delete(worker.descriptor.workerId); - this.deleteWorkerDescriptor(worker); - void this.syncAgentPeers().catch(() => undefined); + // An exact stop owns its registration and descriptor cleanup until its + // tuple assertions complete. A synchronous root shutdown event can arrive + // before its request resolves, so leave both intact while it is active. + if ((this.workerStopCounts?.get(worker) ?? 0) === 0) { + this.workers.delete(worker.descriptor.workerId); + this.deleteWorkerDescriptor(worker); + void this.syncAgentPeers().catch(() => undefined); + } } } @@ -4624,6 +4634,25 @@ export class DaemonSupervisor { return observed === processStartId ? "current" : "replaced"; } + /** + * Keep an exact stop's registration and descriptor authoritative while any + * part of its cleanup is in flight. Root kills acquire this before forwarding + * because a synchronous shutdown event may arrive before the worker replies. + */ + private acquireWorkerStopOwnership(worker: ResidentWorker): () => void { + if (!this.workerStopCounts) this.workerStopCounts = new Map(); + const stopCounts = this.workerStopCounts; + stopCounts.set(worker, (stopCounts.get(worker) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const remaining = (stopCounts.get(worker) ?? 1) - 1; + if (remaining === 0) stopCounts.delete(worker); + else stopCounts.set(worker, remaining); + }; + } + private async stopWorker( worker: ResidentWorker, removeDescriptor: boolean, @@ -4632,15 +4661,11 @@ export class DaemonSupervisor { recoveryCleanup = false, directChild?: { child: ChildProcess; closed: Promise }, ): Promise { - if (!this.workerStopCounts) this.workerStopCounts = new Map(); - const stopCounts = this.workerStopCounts; - stopCounts.set(worker, (stopCounts.get(worker) ?? 0) + 1); + const releaseStopOwnership = this.acquireWorkerStopOwnership(worker); try { await this.stopWorkerUntracked(worker, removeDescriptor, force, archiveSession, recoveryCleanup, directChild); } finally { - const remaining = (stopCounts.get(worker) ?? 1) - 1; - if (remaining === 0) stopCounts.delete(worker); - else stopCounts.set(worker, remaining); + releaseStopOwnership(); } } diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 03bb76264..c9a9ba95f 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -1454,6 +1454,78 @@ describe("daemon worker supervisor monitoring", () => { await stopping; }); + it("keeps a root kill registration through a synchronous shutdown event until exact cleanup", async () => { + const worker = { + descriptor: { + workerId: "worker-root-kill", + pid: 123_456, + processStartId: "proc:entry", + rootActiveSessionId: "root-active", + lifecycle: "ready" as const, + }, + summaries: new Map([ + ["root-active", { id: "root-active", sessionId: "root-session", activeSessionId: "root-active" } as SessionSummary], + ]), + snapshotCache: new Map(), + transcriptCaches: new Map(), + snapshotGenerations: new Map(), + snapshotLoads: new Map(), + intentionalStop: false, + stopRevision: 0, + }; + const workers = new Map([[worker.descriptor.workerId, worker]]); + const deleteWorkerDescriptor = vi.fn(); + const stopWorkerUntracked = vi.fn(async (target: typeof worker, removeDescriptor: boolean) => { + // The root-kill ownership and this exact stop are both active here. + expect(supervisor.workerStopCounts.get(target)).toBe(2); + expect(workers.get(target.descriptor.workerId)).toBe(target); + workers.delete(target.descriptor.workerId); + if (removeDescriptor) deleteWorkerDescriptor(target); + }); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + workers, + workerStopCounts: new Map(), + clients: new Set(), + shuttingDown: false, + streamReconstructor: { observe: vi.fn() }, + invalidateWorkerSnapshot: vi.fn(), + refreshWorkerSummaries: vi.fn(async () => undefined), + syncAgentPeers: vi.fn(async () => undefined), + persistWorkerStopTombstone: vi.fn(), + deleteWorkerDescriptor, + broadcastHeartbeatsChanged: vi.fn(), + findWorkerForClient: vi.fn(async () => ({ + worker, + summary: worker.summaries.get("root-active"), + })), + forwardToWorker: vi.fn(async () => { + supervisor.handleWorkerFrame(worker, { + header: { kind: "outbound", outboundType: "session_closed", activeSessionId: "root-active" }, + payload: Buffer.from(JSON.stringify({ type: "session_closed", reason: "shutdown" })), + }); + // The event arrives before the forwarded kill resolves. + expect(workers.get(worker.descriptor.workerId)).toBe(worker); + expect(deleteWorkerDescriptor).not.toHaveBeenCalled(); + return success(undefined, "kill"); + }), + stopWorkerUntracked, + }) as { + workers: typeof workers; + workerStopCounts: Map; + handleCommand(client: DaemonSocketClient, command: { type: "kill"; activeSessionId: string }): Promise; + handleWorkerFrame(target: typeof worker, frame: PrivateFrame): void; + }; + + await expect(supervisor.handleCommand({} as DaemonSocketClient, { type: "kill", activeSessionId: "root-active" })).resolves.toEqual( + success(undefined, "kill"), + ); + expect(stopWorkerUntracked).toHaveBeenCalledWith(worker, true, false, true, false, undefined); + expect(workers.has(worker.descriptor.workerId)).toBe(false); + expect(deleteWorkerDescriptor).toHaveBeenCalledWith(worker); + expect(supervisor.workerStopCounts.has(worker)).toBe(false); + }); + + it("cancels an in-flight recovery after an intentional stop tombstone", async () => { vi.useFakeTimers(); type RecoveryWorker = { From 1b375ae4091f80105f43259f10ecb7b161b970c5 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 05:33:39 -0700 Subject: [PATCH 2/8] feat(coding-agent): reconstruct managed session catalog --- .../coding-agent/src/cli/command-registry.ts | 6 + .../coding-agent/src/cli/public-command.ts | 57 +++ .../coding-agent/src/core/agent-messages.ts | 236 ++++++------ .../coding-agent/src/core/agent-observe.ts | 55 ++- .../src/core/agent-session-runtime.ts | 6 +- .../coding-agent/src/core/agent-session.ts | 48 ++- packages/coding-agent/src/core/index.ts | 8 + .../coding-agent/src/core/kernel/index.ts | 301 ++++++++++++--- .../src/core/mcp/mcp-declaration-command.ts | 118 ++++++ .../src/core/mcp/mcp-declarations.ts | 170 +++++++++ .../coding-agent/src/core/mcp/mcp-manager.ts | 68 ++-- .../coding-agent/src/core/mcp/mcp-probe.ts | 125 +++++++ .../src/core/mcp/mcp-project-trust.ts | 89 +++++ .../src/core/mcp/mcp-redaction.ts | 35 ++ .../mcp/mcp-runtime-declaration-snapshot.ts | 110 ++++++ .../src/core/mcp/project-trust-authority.ts | 171 +++++++++ packages/coding-agent/src/core/rlm-runtime.ts | 18 +- .../coding-agent/src/core/settings-manager.ts | 56 ++- packages/coding-agent/src/index.ts | 8 + packages/coding-agent/src/main.ts | 30 +- .../src/modes/daemon/daemon-mode.ts | 345 +++++++++++++++--- .../src/modes/daemon/daemon-protocol.ts | 53 +++ .../src/modes/daemon/daemon-supervisor.ts | 112 ++++-- .../modes/daemon/daemon-worker-protocol.ts | 2 + .../test/acp-kernel-features.test.ts | 17 +- .../test/agent-session-bus.test.ts | 90 ++++- .../test/agent-session-recursion.test.ts | 172 ++++++++- .../coding-agent/test/daemon-mode.test.ts | 262 +++++++++++-- .../test/daemon-supervisor-eviction.test.ts | 172 ++++++++- .../coding-agent/test/host-request-context.ts | 56 +++ .../coding-agent/test/kernel-abort.test.ts | 65 +++- .../test/kernel-agent-message-skill.test.ts | 39 +- .../test/kernel-agent-observe-skill.test.ts | 17 +- .../test/kernel-attach-image-skill.test.ts | 70 ++-- .../test/kernel-goal-skill.test.ts | 21 +- .../test/kernel-rlm-heartbeat-skill.test.ts | 21 +- .../coding-agent/test/mcp-manager.test.ts | 15 +- .../4649-subagent-model-selection.test.ts | 7 +- 38 files changed, 2797 insertions(+), 454 deletions(-) create mode 100644 packages/coding-agent/src/core/mcp/mcp-declaration-command.ts create mode 100644 packages/coding-agent/src/core/mcp/mcp-declarations.ts create mode 100644 packages/coding-agent/src/core/mcp/mcp-probe.ts create mode 100644 packages/coding-agent/src/core/mcp/mcp-project-trust.ts create mode 100644 packages/coding-agent/src/core/mcp/mcp-redaction.ts create mode 100644 packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts create mode 100644 packages/coding-agent/src/core/mcp/project-trust-authority.ts create mode 100644 packages/coding-agent/test/host-request-context.ts diff --git a/packages/coding-agent/src/cli/command-registry.ts b/packages/coding-agent/src/cli/command-registry.ts index dff1ee4e3..cbb62558a 100644 --- a/packages/coding-agent/src/cli/command-registry.ts +++ b/packages/coding-agent/src/cli/command-registry.ts @@ -150,6 +150,12 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ usage: "config", summary: "Configure package resources", }, + { + path: ["mcp"], + usage: "mcp ... [--project]", + summary: "Manage declarative MCP endpoint records", + description: "Commands only read or write credential-free declarations. They never start an MCP runtime or authentication flow. A test probe requires an injected local transport.", + }, ]; export const PUBLIC_COMMAND_NAMES = new Set( diff --git a/packages/coding-agent/src/cli/public-command.ts b/packages/coding-agent/src/cli/public-command.ts index 020498e1d..1889e902d 100644 --- a/packages/coding-agent/src/cli/public-command.ts +++ b/packages/coding-agent/src/cli/public-command.ts @@ -1,5 +1,9 @@ import chalk from "chalk"; import { APP_NAME, SELF_UPDATE_INTERACTIVE_CHILD_ENV } from "../config.js"; +import { executeMcpDeclarationCommand, parseMcpDeclarationCommand } from "../core/mcp/mcp-declaration-command.js"; +import { createMcpProjectTrustAuthority } from "../core/index.js"; +import { admitProjectMcpDeclarations } from "../core/mcp/mcp-project-trust.js"; +import { SettingsManager, type Settings } from "../core/settings-manager.js"; import { handlePackageCommand, isSelfUpdateSource } from "../package-manager-cli.js"; import { INTERNAL_RUNTIME_COMMAND_MARKER, parseArgs } from "./args.js"; import { @@ -140,11 +144,64 @@ async function runPublicCommand(args: string[]): Promise { case "config": if (!requireArgumentCount(args.slice(1), 0, "config")) return HANDLED; return continueWith(args); + case "mcp": + return runMcpDeclarationCommand(args.slice(1)); default: return continueWith(args); } } + +/** + * Sole public-command composition point for project MCP policy. It receives a + * SettingsManager already loaded by the CLI and reads only its global snapshot. + * A project settings value can never create a grant. + */ +export function composeMcpProjectDeclarationAdmission( + command: ReturnType, + globalSettings: Pick, + workingDirectory: string, +) { + if (command.scope !== "project") return undefined; + const globalPolicy = globalSettings.mcpProjectTrustPolicy; + const authority = createMcpProjectTrustAuthority({ + revision: typeof globalPolicy?.revision === "string" ? globalPolicy.revision : "", + allowedProjectDirectories: + Array.isArray(globalPolicy?.allowedProjectDirectories) && globalPolicy.allowedProjectDirectories.every((path) => typeof path === "string") + ? globalPolicy.allowedProjectDirectories + : [], + }); + // The only raw-path authorization. Downstream receives no path or authority + // policy, only the opaque admission returned here. + return admitProjectMcpDeclarations(workingDirectory, authority); +} + +async function runMcpDeclarationCommand(args: string[]): Promise { + const command = parseMcpDeclarationCommand(args); + const workingDirectory = process.cwd(); + if (command.scope === "project") { + // This global-only read deliberately precedes SettingsManager.create(): a + // denied/missing/malformed policy must never open project settings. + const admission = composeMcpProjectDeclarationAdmission( + command, + SettingsManager.loadGlobalSettings(workingDirectory), + workingDirectory, + ); + if (!admission) throw new Error("Project MCP declarations are unavailable."); + const settings = SettingsManager.create(workingDirectory); + const result = await executeMcpDeclarationCommand(command, settings, admission); + await settings.flush(); + console.log(JSON.stringify(result, null, 2)); + return HANDLED; + } + // User declarations retain the existing full settings behavior. + const settings = SettingsManager.create(workingDirectory); + const result = await executeMcpDeclarationCommand(command, settings); + await settings.flush(); + console.log(JSON.stringify(result, null, 2)); + return HANDLED; +} + function normalizeLeadingDaemonSocketOption(args: string[]): string[] { const option = args[0]; if (option !== "--daemon-socket") { diff --git a/packages/coding-agent/src/core/agent-messages.ts b/packages/coding-agent/src/core/agent-messages.ts index a030c98ef..d8c36af2d 100644 --- a/packages/coding-agent/src/core/agent-messages.ts +++ b/packages/coding-agent/src/core/agent-messages.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { HostRequestHandler } from "./kernel/index.js"; +import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "./kernel/index.js"; import type { CustomMessage } from "./messages.js"; import { canonicalSessionPath } from "./session-lease.js"; @@ -257,7 +257,20 @@ function sameAgentSessionNameParent( if (left.depth === 0 && right.depth === 0) { return true; } - return sameAgentFamilyParent(left, right, catalog); + if (sameAgentFamilyParent(left, right, catalog)) return true; + + // A passive child can outlive the active parent row that would normally + // resolve its family edge. Name reservation must still protect that parent's + // sibling namespace, but this weaker direct-claim fallback is deliberately + // not used for family reach: reach continues to require an unambiguous, + // catalog-resolved parent. + if (left.depth !== right.depth || left.depth === 0) return false; + return ( + (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) || + (left.parentSessionPath !== undefined && + right.parentSessionPath !== undefined && + canonicalSessionPath(left.parentSessionPath) === canonicalSessionPath(right.parentSessionPath)) + ); } function sameAgentFamilyParent( @@ -265,44 +278,38 @@ function sameAgentFamilyParent( right: AgentSessionNameScope, catalog: readonly AgentFamilyCatalogEntry[], ): boolean { - if (left.parentSessionPath !== undefined && left.parentSessionPath === right.parentSessionPath) { - return true; - } - if (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) { - return true; - } - const hasCatalogParentPair = (parentSessionId: string | undefined, parentSessionPath: string | undefined) => - parentSessionId !== undefined && - parentSessionPath !== undefined && - catalog.some( - (entry) => - (entry.id === parentSessionId && entry.sessionPath === parentSessionPath) || - (entry.parentSessionId === parentSessionId && entry.parentSessionPath === parentSessionPath), + if (left.depth === 0 && right.depth === 0) { + return ( + left.parentSessionId === undefined && + left.parentSessionPath === undefined && + right.parentSessionId === undefined && + right.parentSessionPath === undefined ); - if ( - hasCatalogParentPair(left.parentSessionId, right.parentSessionPath) || - hasCatalogParentPair(right.parentSessionId, left.parentSessionPath) - ) { - return true; } - if ( - left.depth === 0 && - right.depth === 0 && - left.parentSessionPath === undefined && - right.parentSessionPath === undefined && - left.parentSessionId === undefined && - right.parentSessionId === undefined - ) { - return true; - } - // Unresolved mixed identifiers stay unrelated to avoid false name conflicts across families. - return false; + if (left.depth !== right.depth || left.depth === 0) return false; + const parentFor = (child: AgentSessionNameScope) => { + const parents = catalog.filter((entry) => isAgentFamilyParent(entry, child)); + return parents.length === 1 ? parents[0] : undefined; + }; + const leftParent = parentFor(left); + const rightParent = parentFor(right); + return leftParent !== undefined && leftParent.id === rightParent?.id; } -function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamilyCatalogEntry): boolean { +/** + * Validates one persisted parent edge. A child may supply either durable + * identifier, but when it supplies both they must identify this same direct + * parent. This keeps contradictory records from becoming relatives through + * whichever identifier happens to match. + */ +function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentSessionNameScope): boolean { + if (child.depth <= 0 || parent.depth !== child.depth - 1) return false; + const claimsId = child.parentSessionId !== undefined; + const claimsPath = child.parentSessionPath !== undefined; return ( - (child.parentSessionPath !== undefined && child.parentSessionPath === parent.sessionPath) || - (child.parentSessionId !== undefined && child.parentSessionId === parent.id) + (claimsId || claimsPath) && + (!claimsId || child.parentSessionId === parent.id) && + (!claimsPath || child.parentSessionPath === parent.sessionPath) ); } @@ -310,19 +317,21 @@ function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamily export function agentFamilyRelationship( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog: readonly AgentFamilyCatalogEntry[] = [current, target], ): AgentFamilyRelationship | undefined { if (current.id === target.id) return undefined; if (isAgentFamilyParent(target, current)) return "parent"; if (isAgentFamilyParent(current, target)) return "child"; - if (current.depth === target.depth && sameAgentFamilyParent(current, target, [current, target])) return "sibling"; + if (sameAgentFamilyParent(current, target, catalog)) return "sibling"; return undefined; } export function assertAgentFamilyReach( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog?: readonly AgentFamilyCatalogEntry[], ): AgentFamilyRelationship { - const relationship = agentFamilyRelationship(current, target); + const relationship = agentFamilyRelationship(current, target, catalog); if (!relationship) throw new Error(AGENT_FAMILY_REACH_ERROR); return relationship; } @@ -526,83 +535,90 @@ export function createAgentMessageHostHandlers( controller: Pick, ): Record { return { - "agent_message.list_agents": async () => { - if (!controller.roster) throw new Error("agent family roster is not available in this session"); - return (await controller.roster()) as unknown as Record; - }, - "agent_message.send": async (payload) => { - if (typeof payload.message !== "string") { - throw new Error("agent_message.send message must be a string"); - } - let target: string; - if (typeof payload.target === "string") { - if (payload.target !== "all") { - throw new Error( - "positional agent_message.send targets are not supported; use receiver_role and receiver_name", - ); - } - if (payload.receiver_role !== undefined || payload.receiver_name !== undefined) { - throw new Error("agent_message.send broadcast cannot be combined with receiver_role/receiver_name"); - } + "agent_message.list_agents": createHostRequestHandler( + async (_payload: Record, _context: HostRequestContext) => { if (!controller.roster) throw new Error("agent family roster is not available in this session"); - const roster = await controller.roster(); - const results = await Promise.allSettled( - roster.entries.map((entry) => - controller.sendAgentMessage({ - target: entry.id, - message: payload.message as string, - receiverRole: entry.relationship, - }), - ), - ); - const receipts = results.map((result, index) => - result.status === "fulfilled" - ? result.value - : { - target: roster.entries[index]!.id, - error: result.reason instanceof Error ? result.reason.message : String(result.reason), - }, - ); - return { receipts } as unknown as Record; - } else { - const role = payload.receiver_role; - if (role !== "parent" && role !== "sibling" && role !== "child") { - throw new Error('agent_message.send receiver_role must be "parent", "sibling", or "child"'); + return (await controller.roster()) as unknown as Record; + }, + ), + "agent_message.send": createHostRequestHandler( + async (payload: Record, _context: HostRequestContext) => { + if (typeof payload.message !== "string") { + throw new Error("agent_message.send message must be a string"); } - const receiverName = payload.receiver_name; - if (role === "parent" && receiverName !== undefined && receiverName !== null) { - throw new Error("agent_message.send receiver_name must be omitted for parent messages"); - } - if (role !== "parent" && (typeof receiverName !== "string" || !receiverName.trim())) { - throw new Error("agent_message.send receiver_name is required for sibling and child messages"); - } - if (!controller.roster) throw new Error("agent family roster is not available in this session"); - const selector = typeof receiverName === "string" ? receiverName.trim() : undefined; - const publishedId = - role === "child" && selector && controller.awaitPendingChildPublication - ? await controller.awaitPendingChildPublication(selector) - : undefined; - const roster = await controller.roster(); - const matches = roster.entries.filter( - (entry) => - entry.relationship === role && - (role === "parent" || entry.name === selector || entry.id === selector || entry.id === publishedId), - ); - if (matches.length !== 1) { - throw new Error( - matches.length === 0 - ? `No ${role} matches ${role === "parent" ? "the current agent" : JSON.stringify(receiverName)}` - : `${role} selector ${JSON.stringify(receiverName)} is ambiguous`, + let target: string; + if (typeof payload.target === "string") { + if (payload.target !== "all") { + throw new Error( + "positional agent_message.send targets are not supported; use receiver_role and receiver_name", + ); + } + if (payload.receiver_role !== undefined || payload.receiver_name !== undefined) { + throw new Error("agent_message.send broadcast cannot be combined with receiver_role/receiver_name"); + } + if (!controller.roster) throw new Error("agent family roster is not available in this session"); + const roster = await controller.roster(); + const results = await Promise.allSettled( + roster.entries.map((entry) => + controller.sendAgentMessage({ + target: entry.id, + message: payload.message as string, + receiverRole: entry.relationship, + }), + ), + ); + const receipts = results.map((result, index) => + result.status === "fulfilled" + ? result.value + : { + target: roster.entries[index]!.id, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }, ); + return { receipts } as unknown as Record; + } else { + const role = payload.receiver_role; + if (role !== "parent" && role !== "sibling" && role !== "child") { + throw new Error('agent_message.send receiver_role must be "parent", "sibling", or "child"'); + } + const receiverName = payload.receiver_name; + if (role === "parent" && receiverName !== undefined && receiverName !== null) { + throw new Error("agent_message.send receiver_name must be omitted for parent messages"); + } + if (role !== "parent" && (typeof receiverName !== "string" || !receiverName.trim())) { + throw new Error("agent_message.send receiver_name is required for sibling and child messages"); + } + if (!controller.roster) throw new Error("agent family roster is not available in this session"); + const selector = typeof receiverName === "string" ? receiverName.trim() : undefined; + const publishedId = + role === "child" && selector && controller.awaitPendingChildPublication + ? await controller.awaitPendingChildPublication(selector) + : undefined; + const roster = await controller.roster(); + const matches = roster.entries.filter( + (entry) => + entry.relationship === role && + (role === "parent" || + entry.name === selector || + entry.id === selector || + entry.id === publishedId), + ); + if (matches.length !== 1) { + throw new Error( + matches.length === 0 + ? `No ${role} matches ${role === "parent" ? "the current agent" : JSON.stringify(receiverName)}` + : `${role} selector ${JSON.stringify(receiverName)} is ambiguous`, + ); + } + target = matches[0]!.id; } - target = matches[0]!.id; - } - return (await controller.sendAgentMessage({ - target, - message: payload.message, - receiverRole: payload.receiver_role as AgentFamilyRelationship, - })) as unknown as Record; - }, + return (await controller.sendAgentMessage({ + target, + message: payload.message, + receiverRole: payload.receiver_role as AgentFamilyRelationship, + })) as unknown as Record; + }, + ), }; } diff --git a/packages/coding-agent/src/core/agent-observe.ts b/packages/coding-agent/src/core/agent-observe.ts index 045be7757..0c80d1663 100644 --- a/packages/coding-agent/src/core/agent-observe.ts +++ b/packages/coding-agent/src/core/agent-observe.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "./kernel/index.js"; export const AGENT_OBSERVE_SKILL_NAME = "agent-observe"; export const AGENT_OBSERVE_IMPORT_NAME = "agent_observe"; @@ -67,26 +68,42 @@ export interface AgentObserveController { ): AgentObserveRecentMessagesResult | Promise; } -export function createAgentObserveHostHandlers(controller: AgentObserveController) { - return { - "agent_observe.list": async () => controller.listAgents() as unknown as Record, - "agent_observe.get": async (payload: Record = {}) => { - if (typeof payload.target !== "string") { - throw new Error("agent_observe.get target must be a string"); - } - return (await controller.getAgent(payload.target)) as unknown as Record; - }, - "agent_observe.recent": async (payload: Record = {}) => { - if (typeof payload.target !== "string") { - throw new Error("agent_observe.recent target must be a string"); - } - return (await controller.recentMessages({ - target: payload.target, - limit: normalizeOptionalInteger(payload.limit, "agent_observe.recent limit"), - maxChars: normalizeOptionalInteger(payload.max_chars ?? payload.maxChars, "agent_observe.recent max_chars"), - })) as unknown as Record; - }, +export function createAgentObserveHostHandlers(controller: AgentObserveController): Record { + const handlers: Record = { + "agent_observe.list": createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void payload; + void context; // Listing observes the controller's current bounded snapshot only. + return controller.listAgents() as unknown as Record; + }, + ), + "agent_observe.get": createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void context; // The controller validates reachability; this adapter needs no extra request state. + if (typeof payload.target !== "string") { + throw new Error("agent_observe.get target must be a string"); + } + return (await controller.getAgent(payload.target)) as unknown as Record; + }, + ), + "agent_observe.recent": createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void context; // Bounds are enforced below before delegating to the controller. + if (typeof payload.target !== "string") { + throw new Error("agent_observe.recent target must be a string"); + } + return (await controller.recentMessages({ + target: payload.target, + limit: normalizeOptionalInteger(payload.limit, "agent_observe.recent limit"), + maxChars: normalizeOptionalInteger( + payload.max_chars ?? payload.maxChars, + "agent_observe.recent max_chars", + ), + })) as unknown as Record; + }, + ), }; + return handlers; } export function normalizeObserveLimit(limit: number | undefined, defaultLimit = 8): number { diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 198b628f9..393be628a 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -223,7 +223,8 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { }); await flushAgentTraceUpload(this.session.sessionManager).catch(() => undefined); this.beforeSessionInvalidate?.(); - // Await the kernel's final snapshot flush before invalidating the session. + // AgentSession first revokes and awaits kernel host-request handlers before + // this replacement can invalidate the old session's authority. await this.session.disposeAsync(); await this.disposeHostedSubagentRuntimes(); } @@ -724,7 +725,8 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { disposeError ??= error; } try { - // Await the kernel's final snapshot flush before tearing the session down. + // AgentSession revokes and awaits kernel host-request handlers before + // runtime disposal can release this session's resources. await this.session.disposeAsync(); } catch (error) { disposeError ??= error; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 46d2bd4f8..8d5f15b0a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -169,7 +169,12 @@ import { validateGoalBudget, validateGoalObjective, } from "./goals.js"; -import type { HostRequestHandlers, KernelSentAgentMessage } from "./kernel/index.js"; +import { + createHostRequestHandler, + type HostRequestContext, + type HostRequestHandler, + type KernelSentAgentMessage, +} from "./kernel/index.js"; import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js"; import type { McpManager } from "./mcp/mcp-manager.js"; import { @@ -3782,6 +3787,11 @@ export class AgentSession { return this._disposeAsyncPromise; } this._disposeAsyncPromise = (async () => { + // Revoke kernel-originated host requests before awaiting unrelated + // refinement work. The provisioner forwards this to KernelManager, which + // aborts each request and awaits its handler before its connection closes. + // This prevents an old session's host handler from surviving replacement. + await this._ipythonKernelProvisioner?.dispose(); // Drain before marking _disposing so a refine triggered at the final // agent_end completes instead of being aborted by dispose(). await this._drainPendingRefinementForDisposal(); @@ -8758,33 +8768,44 @@ export class AgentSession { } /** Typed handlers for host requests arriving from the IPython kernel comm bridge. */ - private _createKernelHostHandlers(): HostRequestHandlers { - const handlers: HostRequestHandlers = { + private _createKernelHostHandlers(): Record { + const handlers: Record = { "rlm.run": createRlmRunHostHandler(async ({ prompt, kwargs, cellSourceCode }) => ({ ...(await this.runRlmChild(prompt, kwargs, cellSourceCode)), })), "rlm.find_models": createRlmFindModelsHostHandler((query, limit) => this.findRlmModels(query, limit)), "rlm.list_subagents": createRlmListSubagentsHostHandler(() => this.listRlmSubagents()), "rlm.delete_subagent": createRlmDeleteSubagentHostHandler((target) => this.deleteRlmSubagent(target)), - "model.info": async () => ({ - id: this.model?.id ?? null, - provider: this.model?.provider ?? null, - input: this.model?.input ?? [], - }), + "model.info": createHostRequestHandler( + async (_payload: Record, _context: HostRequestContext) => ({ + id: this.model?.id ?? null, + provider: this.model?.provider ?? null, + input: this.model?.input ?? [], + }), + ), }; if (this._includeGoals) { for (const type of ["goal.get", "goal.create", "goal.complete"]) { - handlers[type] = async (payload) => this.handleGoalHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload: Record, _context: HostRequestContext) => + this.handleGoalHostRequest(type, payload), + ); } } if (this._includeCompactSkill) { for (const type of ["compact.run", "compact.status"]) { - handlers[type] = async (payload) => this.handleCompactHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload: Record, _context: HostRequestContext) => + this.handleCompactHostRequest(type, payload), + ); } } if (this._autoRefineAllowedForSession()) { for (const type of ["refine.run", "refine.status"]) { - handlers[type] = async (payload) => this.handleRefineHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload: Record, _context: HostRequestContext) => + this.handleRefineHostRequest(type, payload), + ); } } if (this._rlmHeartbeatController) { @@ -8794,7 +8815,10 @@ export class AgentSession { "rlm_heartbeat.update", "rlm_heartbeat.delete", ]) { - handlers[type] = async (payload) => this.handleRlmHeartbeatHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload: Record, _context: HostRequestContext) => + this.handleRlmHeartbeatHostRequest(type, payload), + ); } } const visibleKernelSkillNames = new Set( diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 587b0f517..f30e669c1 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -77,6 +77,14 @@ export { type TurnStartEvent, type WorkingIndicatorOptions, } from "./extensions/index.js"; +export { + createMcpProjectTrustAuthority, + type McpProjectTrustAuthority, + type McpProjectTrustAuthorityInput, + type McpProjectTrustAuthorization, + type McpProjectTrustBinding, + type McpProjectTrustBindingValidation, +} from "./mcp/project-trust-authority.js"; export type { RefinementResult } from "./refinement/index.js"; export type { CreateRlmSubagentRuntimeOptions, RlmSubagentRuntime, SubagentRuntimeHost } from "./rlm-runtime.js"; export { SessionImportFileNotFoundError } from "./session-import-errors.js"; diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index b760a2e1e..39e62794b 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -29,8 +29,8 @@ const READY_TIMEOUT_MS = 5000; // Loopback PUB/SUB subscription propagation is usually sub-ms, but keep a small guard before first execute. const IOPUB_SUBSCRIBE_DELAY_MS = 50; const DEFAULT_MAX_OUTPUT_CHARS = 65536; -const HOST_REQUEST_DISPOSE_TIMEOUT_MS = 5000; const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500; +const HOST_REQUEST_SETTLE_TIMEOUT_MS = 5000; // How often to poll a forked kernel's pid for unexpected death. const FORKED_LIVENESS_POLL_MS = 1000; // Snapshot/restore cells can be large to (de)serialize; give them room beyond the user cap. @@ -56,10 +56,94 @@ export class KernelBusyAfterInterruptError extends Error { export const HOST_COMM_TARGET = "host.request"; /** - * Handles one typed request from Python code running in the kernel. - * The returned record is sent back verbatim as the comm reply payload. + * Per-call authority supplied only by the kernel host-request dispatcher. + * `requestId` is an opaque host-minted correlation token, never accepted from + * the kernel payload. `isCurrent()` fences completions after a comm disconnect, + * kernel replacement, or disposal. */ -export type HostRequestHandler = (payload: Record) => Promise>; +export interface HostRequestContext { + readonly requestId: string; + readonly generation: number; + readonly signal: AbortSignal; + isCurrent(): boolean; +} + +/** + * Handles one authenticated typed request from Python code running in the + * kernel. The returned record is sent back verbatim as the comm reply payload. + */ +const hostRequestHandlerBrand = Symbol("hostRequestHandler"); + +/** A dispatcher-minted host handler capability. */ +export type HostRequestHandler = (( + payload: Record, + context: HostRequestContext, +) => Promise>) & { readonly [hostRequestHandlerBrand]: true }; + +export type HostRequestHandlerImplementation = ( + payload: Record, + context: HostRequestContext, +) => Promise>; + +/** Runtime provenance cannot be recreated by copying the nominal symbol property. */ +const factoryCreatedHostRequestHandlers = new WeakSet(); + +type ContextAware unknown> = Parameters extends [ + infer Payload, + infer Context, + ...unknown[], +] + ? Record extends Payload + ? HostRequestContext extends Context + ? unknown + : never + : never + : never; + +function assertGenuineHostRequestContext(context: unknown): asserts context is HostRequestContext { + if ( + typeof context !== "object" || + context === null || + typeof (context as HostRequestContext).requestId !== "string" || + !(context as HostRequestContext).requestId || + !Number.isSafeInteger((context as HostRequestContext).generation) || + typeof (context as HostRequestContext).isCurrent !== "function" || + typeof (context as HostRequestContext).signal !== "object" || + (context as HostRequestContext).signal === null || + typeof (context as HostRequestContext).signal.aborted !== "boolean" || + typeof (context as HostRequestContext).signal.addEventListener !== "function" + ) { + throw new Error("host request context is invalid"); + } +} + +/** + * Creates a branded wrapper rather than mutating its implementation. Both its + * generic shape and runtime arity reject unary callbacks before they can run. + */ +export function createHostRequestHandler< + Result extends Promise>, + T extends (...args: any[]) => Result, +>(implementation: T & ContextAware): HostRequestHandler { + if (implementation.length < 2) throw new Error("host request handlers must accept payload and context"); + const handler = async (payload: Record, context: HostRequestContext) => { + assertGenuineHostRequestContext(context); + return (implementation as unknown as HostRequestHandlerImplementation)(payload, context); + }; + factoryCreatedHostRequestHandlers.add(handler); + return Object.defineProperty(handler, hostRequestHandlerBrand, { value: true }) as HostRequestHandler; +} + +/** Reject copied-symbol and raw-function forgeries before they observe authenticated payloads. */ +export function assertHostRequestHandler(value: unknown): asserts value is HostRequestHandler { + if ( + typeof value !== "function" || + (value as Partial)[hostRequestHandlerBrand] !== true || + !factoryCreatedHostRequestHandlers.has(value) + ) { + throw new Error("host request handler is not a dispatcher-created capability"); + } +} /** Host request handlers keyed by request type (e.g. "rlm.run", "goal.complete"). */ export type HostRequestHandlers = Record; @@ -328,6 +412,16 @@ interface Deferred { reject: (error: Error) => void; } +interface ActiveHostRequest { + requestId: string; + commId: string; + generation: number; + controller: AbortController; + callerSignal?: AbortSignal; + onCallerAbort?: () => void; + settled: boolean; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -336,6 +430,44 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +const MAX_HOST_REQUEST_PAYLOAD_BYTES = 64 * 1024; +const MAX_HOST_REQUEST_PAYLOAD_DEPTH = 8; +const MAX_HOST_REQUEST_PAYLOAD_NODES = 1024; +const MAX_HOST_REQUEST_PAYLOAD_KEYS = 128; + +/** Reject pathological comm data before a typed handler can observe it. */ +function assertBoundedHostRequestPayload(value: unknown): void { + let estimatedBytes = 0; + let nodes = 0; + const visit = (item: unknown, depth: number): void => { + if (depth > MAX_HOST_REQUEST_PAYLOAD_DEPTH) throw new Error("host request payload is too deeply nested"); + nodes += 1; + if (nodes > MAX_HOST_REQUEST_PAYLOAD_NODES) throw new Error("host request payload has too many values"); + if (typeof item === "string") { + estimatedBytes += Buffer.byteLength(item); + } else if (typeof item === "number") { + if (!Number.isFinite(item)) throw new Error("host request payload numbers must be finite"); + estimatedBytes += 16; + } else if (typeof item === "boolean" || item === null) { + estimatedBytes += 8; + } else if (Array.isArray(item)) { + for (const entry of item) visit(entry, depth + 1); + } else if (isRecord(item)) { + const entries = Object.entries(item); + if (entries.length > MAX_HOST_REQUEST_PAYLOAD_KEYS) + throw new Error("host request payload has too many object keys"); + for (const [key, entry] of entries) { + estimatedBytes += Buffer.byteLength(key); + visit(entry, depth + 1); + } + } else { + throw new Error("host request payload must contain JSON-compatible values"); + } + if (estimatedBytes > MAX_HOST_REQUEST_PAYLOAD_BYTES) throw new Error("host request payload is too large"); + }; + visit(value, 0); +} + function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (error: Error) => void; @@ -517,6 +649,10 @@ export class KernelManager { private readonly session = uuid(); private readonly commTargets = new Map(); private readonly handledHostRequestCommIds = new Set(); + /** Monotonically revokes all host-request authority across kernel lifecycles. */ + private hostRequestGeneration = 0; + private readonly activeHostRequests = new Map(); + private readonly hostRequestIdsByComm = new Map(); private kernel?: ChildProcess; // Set instead of `kernel` when the kernel was forked from the forkserver: it is // not a direct child, so it has no ChildProcess handle and is killed by pid. @@ -1197,6 +1333,7 @@ export class KernelManager { if (msgType === "comm_close") { this.commTargets.delete(commId); this.handledHostRequestCommIds.delete(commId); + this.revokeHostRequestForComm(commId); return; } @@ -1224,34 +1361,108 @@ export class KernelManager { } this.handledHostRequestCommIds.add(commId); + const callerSignal = this.activeExecution?.opts.signal; + const request: ActiveHostRequest = { + requestId: uuid(), + commId, + generation: this.hostRequestGeneration, + controller: new AbortController(), + callerSignal, + settled: false, + }; + request.onCallerAbort = () => request.controller.abort(); + callerSignal?.addEventListener("abort", request.onCallerAbort, { once: true }); + if (callerSignal?.aborted) request.controller.abort(); + this.activeHostRequests.set(request.requestId, request); + this.hostRequestIdsByComm.set(commId, request.requestId); + const task = (async () => { try { - const result = await this.handleHostRequest(data); - try { - await this.sendCommMessage(commId, { status: "ok", ...result }); - } catch (replyError) { - this.appendKernelDiagnostic( - `failed to send host request ok reply for comm ${commId}: ${errorMessage(replyError)}`, - ); - } + const result = await this.handleHostRequest(data, this.hostRequestContext(request)); + await this.replyToHostRequest(request, { status: "ok", ...result }); } catch (error) { + // Teardown revokes reply authority, but it must not discard a failure + // already observed by an in-flight handler. `dispose()` waits for this + // task specifically so the manager retains this diagnostic after sockets + // have been revoked. this.appendKernelDiagnostic(`host request failed for comm ${commId}: ${errorMessage(error)}`); - try { - await this.sendCommMessage(commId, { status: "error", error: errorMessage(error) }); - } catch (replyError) { - this.appendKernelDiagnostic( - `failed to send host request error reply for comm ${commId}: ${errorMessage(replyError)}`, - ); - } + await this.replyToHostRequest(request, { status: "error", error: errorMessage(error) }); } })(); this.inFlightHostRequests.add(task); void task.finally(() => { this.inFlightHostRequests.delete(task); + request.callerSignal?.removeEventListener("abort", request.onCallerAbort!); + this.activeHostRequests.delete(request.requestId); + if (this.hostRequestIdsByComm.get(commId) === request.requestId) { + this.hostRequestIdsByComm.delete(commId); + } }); } - private async handleHostRequest(data: unknown): Promise> { + private hostRequestContext(request: ActiveHostRequest): HostRequestContext { + const context: HostRequestContext = { + requestId: request.requestId, + generation: request.generation, + signal: request.controller.signal, + isCurrent: () => this.isHostRequestCurrent(request), + }; + return context; + } + + private isHostRequestCurrent(request: ActiveHostRequest): boolean { + return ( + this.state !== "shutdown" && + this.hostRequestGeneration === request.generation && + this.commTargets.get(request.commId) === HOST_COMM_TARGET && + this.hostRequestIdsByComm.get(request.commId) === request.requestId && + !request.controller.signal.aborted + ); + } + + private revokeHostRequestForComm(commId: string): void { + const requestId = this.hostRequestIdsByComm.get(commId); + if (!requestId) return; + this.hostRequestIdsByComm.delete(commId); + this.activeHostRequests.get(requestId)?.controller.abort(); + } + + /** Abort every active request before a kernel connection is replaced or closed. */ + private revokeHostRequests(): void { + this.hostRequestGeneration += 1; + for (const request of this.activeHostRequests.values()) { + request.controller.abort(); + } + this.hostRequestIdsByComm.clear(); + } + + private async replyToHostRequest(request: ActiveHostRequest, data: Record): Promise { + // Claim the one reply before awaiting I/O so a late handler cannot double-send. + if (request.settled) return; + if (!this.isHostRequestCurrent(request)) { + // Revocation intentionally prevents a stale handler from sending into a + // replaced or closed comm. Still retain the failed error-reply diagnostic: + // disposal waits for this task and callers need its failure trail after + // the transport has been cleaned up. + if (data.status === "error") { + this.appendKernelDiagnostic( + `failed to send host request error reply for comm ${request.commId}: host request authority was revoked`, + ); + } + return; + } + request.settled = true; + try { + await this.sendCommMessage(request.commId, data); + } catch (replyError) { + this.appendKernelDiagnostic( + `failed to send host request ${data.status === "error" ? "error " : ""}reply for comm ${request.commId}: ${errorMessage(replyError)}`, + ); + } + } + + private async handleHostRequest(data: unknown, context: HostRequestContext): Promise> { + assertBoundedHostRequestPayload(data); if (!isRecord(data)) { throw new Error("host request payload must be an object"); } @@ -1263,11 +1474,12 @@ export class KernelManager { if (!handler) { throw new Error(`host request type "${data.type}" is not available in this session`); } + assertHostRequestHandler(handler); // Tag the request with the cell that triggered it. A blocking call is still // the in-flight execution; detached spawns (asyncio.create_task) fire after // the scheduling cell goes idle, so fall back to that last cell's source. const cellSourceCode = this.activeExecution?.code ?? this.lastCellCode; - return handler({ ...data, cellSourceCode }); + return handler({ ...data, cellSourceCode }, context); } private async sendCommMessage(commId: string, data: Record): Promise { @@ -1286,6 +1498,9 @@ export class KernelManager { } private cleanupResources(killSignal: NodeJS.Signals = "SIGTERM"): void { + this.revokeHostRequests(); + this.commTargets.clear(); + this.handledHostRequestCommIds.clear(); this.clearSnapshotTimer(); this.lateSentAgentMessageHandlers.clear(); if (this.forkedLivenessTimer) { @@ -1325,29 +1540,30 @@ export class KernelManager { this.startPromise = undefined; } - private async waitForHostRequestsToSettle(tasks: Promise[], timeoutMs: number): Promise { - let timeout: ReturnType | undefined; - const timeoutPromise = new Promise<"timeout">((resolve) => { - timeout = globalThis.setTimeout(() => resolve("timeout"), timeoutMs); - if (timeout && typeof timeout === "object" && "unref" in timeout) { - timeout.unref(); - } + /** All active typed handlers must observe revocation and settle before teardown continues. */ + private async waitForHostRequestsToSettle(tasks: Promise[]): Promise { + if (tasks.length === 0) return; + let timeout: ReturnType | undefined; + const settled = Promise.allSettled(tasks).then(() => "settled" as const); + const timedOut = new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), HOST_REQUEST_SETTLE_TIMEOUT_MS); + timeout.unref?.(); }); - - const result = await Promise.race([Promise.allSettled(tasks).then(() => "settled" as const), timeoutPromise]); - if (timeout) { - globalThis.clearTimeout(timeout); - } - if (result === "timeout") { + const outcome = await Promise.race([settled, timedOut]); + if (timeout) clearTimeout(timeout); + if (outcome === "timeout") { this.appendKernelDiagnostic( - `timed out waiting ${timeoutMs}ms for ${tasks.length} host request task(s) during dispose`, + `timed out waiting ${HOST_REQUEST_SETTLE_TIMEOUT_MS}ms for ${tasks.length} host request task(s) after revocation`, ); } } async shutdown(opts: { snapshot?: boolean } = {}): Promise { + this.revokeHostRequests(); + const inFlightHostRequests = [...this.inFlightHostRequests]; if (this.state === "shutdown") { liveKernels.delete(this); + await this.waitForHostRequestsToSettle(inFlightHostRequests); this.cleanupResources(); return; } @@ -1371,6 +1587,7 @@ export class KernelManager { ); } + await this.waitForHostRequestsToSettle(inFlightHostRequests); this.cleanupResources(); } @@ -1393,8 +1610,11 @@ export class KernelManager { } async kill(): Promise { + this.revokeHostRequests(); + const inFlightHostRequests = [...this.inFlightHostRequests]; this.state = "shutdown"; liveKernels.delete(this); + await this.waitForHostRequestsToSettle(inFlightHostRequests); this.cleanupResources("SIGKILL"); } @@ -1496,19 +1716,17 @@ export class KernelManager { } } - /** Graceful cleanup. Waits briefly for in-flight host request handlers before closing sockets. */ + /** Graceful cleanup. Revokes and awaits every in-flight typed host handler before closing sockets. */ dispose(): Promise { return (async () => { + this.revokeHostRequests(); + const inFlightHostRequests = [...this.inFlightHostRequests]; // Final namespace flush while the kernel is still live (session end / reload). await this.flushSnapshotForDispose(); this.state = "shutdown"; liveKernels.delete(this); - const inFlightHostRequests = [...this.inFlightHostRequests]; - // TODO: plumb AbortSignal through AgentSession.prompt so disposal can cancel long-running child loops. try { - if (inFlightHostRequests.length > 0) { - await this.waitForHostRequestsToSettle(inFlightHostRequests, HOST_REQUEST_DISPOSE_TIMEOUT_MS); - } + await this.waitForHostRequestsToSettle(inFlightHostRequests); } finally { this.cleanupResources(); } @@ -1517,6 +1735,7 @@ export class KernelManager { /** Synchronous best-effort cleanup. Safe to call from `process.on('exit')`. */ disposeSync(): void { + this.revokeHostRequests(); this.state = "shutdown"; liveKernels.delete(this); // TODO: replace this best-effort hard-exit path if Node exposes an awaitable process-exit cleanup hook. diff --git a/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts b/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts new file mode 100644 index 000000000..4efdb6abe --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts @@ -0,0 +1,118 @@ +import type { SettingsManager } from "../settings-manager.js"; +import { + addMcpDeclaration, + parseMcpDeclarationDocument, + previewMcpProbe, + removeMcpDeclaration, + type McpDeclarationScope, +} from "./mcp-declarations.js"; +import { redactMcpDeclaration, redactMcpDeclarationDocument } from "./mcp-redaction.js"; +import { + runMcpDeclarationProbe, + type McpDeclarationProbeOptions, + type McpProbeTransport, +} from "./mcp-probe.js"; +import { + requireProjectMcpDeclarationAdmission, + type ProjectMcpDeclarationAdmission, +} from "./mcp-project-trust.js"; + +export type McpDeclarationCommand = + | { kind: "list"; scope: McpDeclarationScope } + | { kind: "inspect"; scope: McpDeclarationScope; name: string } + | { kind: "preview"; scope: McpDeclarationScope; name: string } + | { kind: "test"; scope: McpDeclarationScope; name: string } + | { kind: "add"; scope: McpDeclarationScope; name: string; url: string } + | { kind: "enable" | "disable" | "remove"; scope: McpDeclarationScope; name: string }; + +function usage(): never { + throw new Error("Usage: prime-agent mcp ... [--project]"); +} + +function parseScope(words: string[]): { words: string[]; scope: McpDeclarationScope } { + const projectIndexes = words.reduce((indexes, word, index) => (word === "--project" ? [...indexes, index] : indexes), []); + if (projectIndexes.length > 1 || (projectIndexes.length === 1 && projectIndexes[0] !== words.length - 1)) usage(); + return { words: words.filter((word) => word !== "--project"), scope: projectIndexes.length ? "project" : "user" }; +} + +/** Parses declarative commands only. Parsing has no storage, auth, or I/O side effects. */ +export function parseMcpDeclarationCommand(args: string[]): McpDeclarationCommand { + const { words, scope } = parseScope(args); + const [kind, ...operands] = words; + if (kind === "list" && operands.length === 0) return { kind, scope }; + if ( + (kind === "inspect" || kind === "preview" || kind === "test" || kind === "enable" || kind === "disable" || kind === "remove") && + operands.length === 1 + ) { + return { kind, scope, name: operands[0]! }; + } + if (kind === "add" && operands.length === 2) return { kind, scope, name: operands[0]!, url: operands[1]! }; + usage(); +} + +function documentForScope( + settings: SettingsManager, + scope: McpDeclarationScope, + admission: ProjectMcpDeclarationAdmission | undefined, +) { + // Validate before every project settings read; this is intentionally before + // getMcpDeclarationDocument so denied state is inert without a project read. + if (scope === "project") requireProjectMcpDeclarationAdmission(admission); + return settings.getMcpDeclarationDocument(scope); +} + +function writeDocumentForScope( + settings: SettingsManager, + scope: McpDeclarationScope, + document: ReturnType, + admission: ProjectMcpDeclarationAdmission | undefined, +): void { + // Validate again at each privileged mutation; no raw path is available here. + if (scope === "project") requireProjectMcpDeclarationAdmission(admission); + settings.setMcpDeclarationDocument(scope, document); +} + +export interface McpDeclarationCommandOptions extends McpDeclarationProbeOptions { + /** Deliberately supplied only by a local caller or test; no default transport exists. */ + probeTransport?: McpProbeTransport; +} + +export async function executeMcpDeclarationCommand( + command: McpDeclarationCommand, + settings: SettingsManager, + admission?: ProjectMcpDeclarationAdmission, + options: McpDeclarationCommandOptions = {}, +): Promise { + const document = documentForScope(settings, command.scope, admission); + if (command.kind === "list") return redactMcpDeclarationDocument(document); + const declaration = document.servers[command.name]; + if (command.kind === "add") { + const next = addMcpDeclaration(document, command.name, command.url); + writeDocumentForScope(settings, command.scope, next, admission); + return redactMcpDeclaration(next.servers[command.name]!); + } + if (!declaration) throw new Error("No MCP declaration has that name."); + if (command.kind === "inspect") return redactMcpDeclaration(declaration); + if (command.kind === "preview") return previewMcpProbe(redactMcpDeclaration(declaration)); + if (command.kind === "test") { + if (!options.probeTransport) { + throw new Error("MCP probe is unavailable in this command context."); + } + return runMcpDeclarationProbe(declaration, options.probeTransport, { + offline: options.offline, + // A project probe receives a grant only after a fresh Core validation. + trusted: command.scope === "user" || requireProjectMcpDeclarationAdmission(admission) !== undefined, + timeoutMs: options.timeoutMs, + }); + } + if (command.kind === "remove") { + writeDocumentForScope(settings, command.scope, removeMcpDeclaration(document, command.name), admission); + return { removed: command.name }; + } + const next = parseMcpDeclarationDocument({ + version: 1, + servers: { ...document.servers, [command.name]: { ...declaration, enabled: command.kind === "enable" } }, + }); + writeDocumentForScope(settings, command.scope, next, admission); + return redactMcpDeclaration(next.servers[command.name]!); +} diff --git a/packages/coding-agent/src/core/mcp/mcp-declarations.ts b/packages/coding-agent/src/core/mcp/mcp-declarations.ts new file mode 100644 index 000000000..f8c266967 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-declarations.ts @@ -0,0 +1,170 @@ +/** + * M01's declarative, credential-free MCP record. This module has no transport, + * authentication, or process-launch dependency. + */ +export const MCP_DECLARATION_VERSION = 1 as const; +export const MCP_DECLARATION_NAME = /^[a-z][a-z0-9-]{0,62}$/; + +export interface McpDeclaration { + name: string; + url: string; + enabled: boolean; +} + +export interface McpDeclarationDocument { + version: typeof MCP_DECLARATION_VERSION; + servers: Record; +} + +export type McpDeclarationScope = "user" | "project"; + +function fail(message: string): never { + // Deliberately never include supplied configuration values in errors: callers + // may have provided an accidentally credential-bearing URL or field. + throw new Error(message); +} + +/** + * Configuration crosses a hostile-data boundary. Only ordinary (or null + * prototype) records with enumerable own data properties are accepted. This + * intentionally rejects accessors, inherited fields, symbols, and exotic + * prototype chains before any configured value is read. + */ +function ownDataRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + try { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (Object.getOwnPropertySymbols(value).length !== 0) return false; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) return false; + } + return true; + } catch { + return false; + } +} + +function ownDataKeys(record: Record, message: string): string[] { + if (!ownDataRecord(record)) fail(message); + try { + return Object.getOwnPropertyNames(record); + } catch { + return fail(message); + } +} + +function ownDataValue(record: Record, key: string, message: string): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) fail(message); + return descriptor.value; + } catch { + return fail(message); + } +} + +export function normalizeMcpDeclarationName(value: unknown): string { + if (typeof value !== "string" || !MCP_DECLARATION_NAME.test(value)) { + fail("MCP declaration names must start with a lowercase letter and contain lowercase letters, digits, or hyphens."); + } + return value; +} + +/** Canonical, non-credential-bearing Streamable HTTP endpoint identity. */ +export function normalizeMcpDeclarationUrl(value: unknown): string { + if (typeof value !== "string" || /[\s\\]/.test(value)) { + fail("MCP declaration URLs must be a single HTTP(S) URL."); + } + let url: URL; + try { + url = new URL(value); + } catch { + fail("MCP declaration URLs must be a valid HTTP(S) URL."); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + fail("MCP declaration URLs must use HTTP or HTTPS."); + } + if (url.username || url.password || url.search || url.hash) { + fail("MCP declaration URLs must not contain credentials, query strings, or fragments."); + } + return url.toString(); +} + +function requireExactKeys(record: Record, keys: readonly string[], message: string): void { + const actual = ownDataKeys(record, message); + if (actual.length !== keys.length || actual.some((key) => !keys.includes(key))) fail(message); +} + +export function parseMcpDeclaration(value: unknown, expectedName?: string): McpDeclaration { + if (!ownDataRecord(value)) fail("MCP declaration must be a plain object with own data fields."); + requireExactKeys(value, ["name", "url", "enabled"], "MCP declarations only permit name, url, and enabled fields."); + const name = normalizeMcpDeclarationName(ownDataValue(value, "name", "MCP declaration name must be an own data field.")); + if (expectedName !== undefined && name !== expectedName) { + fail("MCP declaration name must match its settings key."); + } + const enabled = ownDataValue(value, "enabled", "MCP declaration enabled must be an own data field."); + if (typeof enabled !== "boolean") fail("MCP declaration enabled must be a boolean."); + return { name, url: normalizeMcpDeclarationUrl(ownDataValue(value, "url", "MCP declaration URL must be an own data field.")), enabled }; +} + +export function emptyMcpDeclarationDocument(): McpDeclarationDocument { + return { version: MCP_DECLARATION_VERSION, servers: {} }; +} + +export function parseMcpDeclarationDocument(value: unknown): McpDeclarationDocument { + if (value === undefined) return emptyMcpDeclarationDocument(); + if (!ownDataRecord(value)) fail("MCP declaration settings must be a plain object with own data fields."); + requireExactKeys(value, ["version", "servers"], "MCP declarations only permit version and servers fields."); + if (ownDataValue(value, "version", "MCP declaration version must be an own data field.") !== MCP_DECLARATION_VERSION) { + fail("MCP declaration settings use an unsupported format."); + } + const rawServers = ownDataValue(value, "servers", "MCP declaration servers must be an own data field."); + if (!ownDataRecord(rawServers)) fail("MCP declaration settings use an unsupported format."); + + const servers: Record = {}; + const urls = new Set(); + for (const key of ownDataKeys(rawServers, "MCP declaration servers must be plain own data.")) { + const name = normalizeMcpDeclarationName(key); + const parsed = parseMcpDeclaration(ownDataValue(rawServers, key, "MCP declaration server must be an own data field."), name); + if (urls.has(parsed.url)) fail("MCP declarations must not repeat an endpoint URL."); + urls.add(parsed.url); + Object.defineProperty(servers, name, { value: parsed, enumerable: true, configurable: true, writable: true }); + } + return { version: MCP_DECLARATION_VERSION, servers }; +} + +export function addMcpDeclaration( + document: McpDeclarationDocument, + name: unknown, + url: unknown, +): McpDeclarationDocument { + const parsedName = normalizeMcpDeclarationName(name); + const parsedUrl = normalizeMcpDeclarationUrl(url); + if (Object.hasOwn(document.servers, parsedName)) fail("An MCP declaration with that name already exists."); + if (Object.values(document.servers).some((server) => server.url === parsedUrl)) { + fail("An MCP declaration with that endpoint URL already exists."); + } + return { + version: MCP_DECLARATION_VERSION, + servers: { ...document.servers, [parsedName]: { name: parsedName, url: parsedUrl, enabled: true } }, + }; +} + +export function removeMcpDeclaration(document: McpDeclarationDocument, name: unknown): McpDeclarationDocument { + const parsedName = normalizeMcpDeclarationName(name); + if (!Object.hasOwn(document.servers, parsedName)) fail("No MCP declaration has that name."); + const { [parsedName]: _removed, ...servers } = document.servers; + return { version: MCP_DECLARATION_VERSION, servers }; +} + +/** A static probe request description. Creating it never performs I/O. */ +export function previewMcpProbe(declaration: McpDeclaration): { + url: string; + method: "POST"; + redirect: "error"; + requestKind: "mcp-initialize"; +} { + return { url: declaration.url, method: "POST", redirect: "error", requestKind: "mcp-initialize" }; +} diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index 58eafb663..b69fa2c0d 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -9,6 +9,7 @@ import { } from "@earendil-works/pi-ai/mcp"; import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/pi-ai/oauth"; import type { AuthStorage } from "../auth-storage.js"; +import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "../kernel/index.js"; import type { McpServerConfig } from "../settings-manager.js"; export interface McpManagerOptions { @@ -153,42 +154,51 @@ export class McpManager { } /** Host-request handlers exposed to the kernel. */ - hostHandlers(): Record) => Promise>> { - const handlers: Record) => Promise>> = { - "mcp.refresh": async (payload) => { - const server = String(payload.server ?? ""); - if (!server) throw new Error("mcp.refresh requires a server"); - // getApiKey refreshes + rewrites auth.json under lock; Python re-reads. - // Surface failure (throw) instead of a false success so the kernel can - // report a refresh error rather than a misleading "not enabled". - const key = await this.authStorage.getApiKey(this.providerId(server)); - if (!key) throw new Error(`Could not refresh credentials for ${server}`); - return {}; - }, + hostHandlers(): Record { + const handlers: Record = { + "mcp.refresh": createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void context; // Credentials are refreshed atomically by AuthStorage; no per-request state is needed. + const server = String(payload.server ?? ""); + if (!server) throw new Error("mcp.refresh requires a server"); + // getApiKey refreshes + rewrites auth.json under lock; Python re-reads. + // Surface failure (throw) instead of a false success so the kernel can + // report a refresh error rather than a misleading "not enabled". + const key = await this.authStorage.getApiKey(this.providerId(server)); + if (!key) throw new Error(`Could not refresh credentials for ${server}`); + return {}; + }, + ), // Resolved config so the kernel skill connects to the same URL the host // registered/authenticated (honors a user's mcpServers `url` override). - "mcp.config": async (payload) => { - const server = String(payload.server ?? ""); - if (!server) throw new Error("mcp.config requires a server"); - const integration = this.integrations.get(server); - if (!integration) return {}; - const config: Record = { url: integration.url }; - if (integration.headers && Object.keys(integration.headers).length > 0) { - config.headers = integration.headers; - } - return config; - }, + "mcp.config": createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void context; // Config is a synchronous snapshot of this manager's resolved integrations. + const server = String(payload.server ?? ""); + if (!server) throw new Error("mcp.config requires a server"); + const integration = this.integrations.get(server); + if (!integration) return {}; + const config: Record = { url: integration.url }; + if (integration.headers && Object.keys(integration.headers).length > 0) { + config.headers = integration.headers; + } + return config; + }, + ), }; // Only expose begin_login when an interactive login is actually wired, so the // kernel doesn't get a handler whose only behavior is to throw. const beginLogin = this.beginLogin; if (beginLogin) { - handlers["mcp.begin_login"] = async (payload) => { - const server = String(payload.server ?? ""); - if (!server) throw new Error("mcp.begin_login requires a server"); - await beginLogin(server); - return {}; - }; + handlers["mcp.begin_login"] = createHostRequestHandler( + async (payload: Record, context: HostRequestContext) => { + void context; // The UI-owned login flow has no request-scoped cancellation hook. + const server = String(payload.server ?? ""); + if (!server) throw new Error("mcp.begin_login requires a server"); + await beginLogin(server); + return {}; + }, + ); } return handlers; } diff --git a/packages/coding-agent/src/core/mcp/mcp-probe.ts b/packages/coding-agent/src/core/mcp/mcp-probe.ts new file mode 100644 index 000000000..8823b2baa --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-probe.ts @@ -0,0 +1,125 @@ +import type { McpDeclaration } from "./mcp-declarations.js"; + +/** The probe never constructs a network client. Callers must inject a local test transport. */ +export interface McpProbeTransport { + open(request: McpProbeOpenRequest): Promise; +} + +export interface McpProbeOpenRequest { + url: string; + signal: AbortSignal; +} + +export interface McpProbeSession { + request(request: McpProbeRequest): Promise; + close(): Promise | void; +} + +export interface McpProbeRequest { + method: "initialize" | "tools/list"; + params?: Record; + signal: AbortSignal; +} + +export interface McpDeclarationProbeOptions { + /** Explicit offline mode blocks before the injected transport is opened. */ + offline?: boolean; + /** A project declaration must have passed the C05 trust boundary first. */ + trusted?: boolean; + /** Total wall-clock budget for opening, both protocol requests, and close. */ + timeoutMs?: number; +} + +export interface McpDeclarationProbeResult { + initialized: true; + toolsListed: true; +} + +const DEFAULT_TIMEOUT_MS = 2_000; +const MAX_TIMEOUT_MS = 10_000; + +function boundedTimeout(value: number | undefined): number { + if (value === undefined) return DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(value) || value <= 0) throw new Error("MCP probe timeout must be a positive finite number."); + return Math.min(Math.floor(value), MAX_TIMEOUT_MS); +} + +function publicProbeError(kind: "disabled" | "offline" | "untrusted" | "timeout" | "failed"): Error { + // Never expose endpoint, transport, or protocol error text: any of these can + // carry an accidentally credential-bearing URL or response payload. + if (kind === "disabled") return new Error("MCP probe is unavailable because this declaration is disabled."); + if (kind === "offline") return new Error("MCP probe is unavailable while offline."); + if (kind === "untrusted") return new Error("MCP probe is unavailable because this declaration is not trusted."); + if (kind === "timeout") return new Error("MCP probe timed out."); + return new Error("MCP probe failed."); +} + +function withDeadline(promise: Promise | T, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(publicProbeError("timeout")); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); +} + +/** + * Executes the smallest possible read-only MCP handshake using an injected + * transport. It has no SDK, fetch, auth, or endpoint implementation, and is + * therefore usable only by an explicitly supplied local fake/adapter. + */ +export async function runMcpDeclarationProbe( + declaration: McpDeclaration, + transport: McpProbeTransport, + options: McpDeclarationProbeOptions = {}, +): Promise { + // These guards intentionally precede *all* transport work. + if (!declaration.enabled) throw publicProbeError("disabled"); + if (options.offline) throw publicProbeError("offline"); + if (options.trusted !== true) throw publicProbeError("untrusted"); + + const timeoutMs = boundedTimeout(options.timeoutMs); + const controller = new AbortController(); + const deadlineTimer = setTimeout(() => controller.abort(), timeoutMs); + let session: McpProbeSession | undefined; + let failure = false; + try { + session = await withDeadline(transport.open({ url: declaration.url, signal: controller.signal }), controller.signal); + await withDeadline( + session.request({ + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "Prime Agent" }, + }, + signal: controller.signal, + }), + controller.signal, + ); + await withDeadline(session.request({ method: "tools/list", signal: controller.signal }), controller.signal); + return { initialized: true, toolsListed: true }; + } catch (error) { + failure = true; + controller.abort(); + throw error instanceof Error && error.message === "MCP probe timed out." + ? error + : publicProbeError("failed"); + } finally { + if (session) { + try { + // Invoke close even after cancellation. The injected session owns its + // local cleanup and cannot be left open by a failed handshake. + await withDeadline(session.close(), controller.signal); + } catch { + // A close failure must never disclose transport data. Preserve an + // earlier request failure, but do not report a false success when + // cleanup itself failed or exceeded the total deadline. + if (!failure) { + throw controller.signal.aborted ? publicProbeError("timeout") : publicProbeError("failed"); + } + } + } + clearTimeout(deadlineTimer); + } +} diff --git a/packages/coding-agent/src/core/mcp/mcp-project-trust.ts b/packages/coding-agent/src/core/mcp/mcp-project-trust.ts new file mode 100644 index 000000000..9aad0325a --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-project-trust.ts @@ -0,0 +1,89 @@ +import type { + McpProjectTrustAuthority, + McpProjectTrustAuthorization, + McpProjectTrustBinding, + McpProjectTrustBindingValidation, +} from "./project-trust-authority.js"; +import { isMcpProjectTrustAuthority } from "./project-trust-authority.js"; +import { emptyMcpDeclarationDocument, type McpDeclarationDocument } from "./mcp-declarations.js"; + +/** + * A branded, empty capability. Its authority/binding pair never appears on the + * object: membership is checked before that pair is ever dereferenced. + */ +export interface ProjectMcpDeclarationAdmission {} + +interface AdmissionPair { + readonly authority: McpProjectTrustAuthority; + readonly binding: McpProjectTrustBinding; +} + +const admissions = new WeakSet(); +const admissionPairs = new WeakMap(); +const DENIED: McpProjectTrustBindingValidation = Object.freeze({ kind: "denied" }); +const GRANTED: McpProjectTrustBindingValidation = Object.freeze({ kind: "granted" }); + +export function admitProjectMcpDeclarations( + rawProjectDirectory: string, + authority: McpProjectTrustAuthority | undefined, +): ProjectMcpDeclarationAdmission | undefined { + if (!isMcpProjectTrustAuthority(authority)) return undefined; + let authorization: McpProjectTrustAuthorization; + try { + authorization = authority.authorizeProjectDirectory(rawProjectDirectory); + } catch { + return undefined; + } + if (authorization.kind !== "granted") return undefined; + + const admission = Object.freeze(Object.create(null)); + admissions.add(admission); + admissionPairs.set(admission, Object.freeze({ authority, binding: authorization.binding })); + return admission as ProjectMcpDeclarationAdmission; +} + +/** + * This membership test intentionally precedes the WeakMap read. A forged + * envelope cannot cause a supplied authority, binding, or accessor to be + * consulted. + */ +export function validateProjectMcpDeclarationAdmission( + admission: ProjectMcpDeclarationAdmission | undefined, +): McpProjectTrustBindingValidation { + if (typeof admission !== "object" || admission === null || !admissions.has(admission)) return DENIED; + const pair = admissionPairs.get(admission); + if (!pair) return DENIED; + try { + return pair.authority.validateBinding(pair.binding).kind === "granted" ? GRANTED : DENIED; + } catch { + return DENIED; + } +} + +export function requireProjectMcpDeclarationAdmission( + admission: ProjectMcpDeclarationAdmission | undefined, +): ProjectMcpDeclarationAdmission { + if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") { + throw new Error("Project MCP declarations are unavailable."); + } + return admission!; +} + +export interface ProjectMcpDeclarations { + document: McpDeclarationDocument; + effective: boolean; +} + +/** + * A denied, missing, stale, foreign, or forged capability makes declarations + * inert. The caller must validate before any project settings read or write. + */ +export function resolveProjectMcpDeclarations( + document: McpDeclarationDocument, + admission: ProjectMcpDeclarationAdmission | undefined, +): ProjectMcpDeclarations { + if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") { + return { document: emptyMcpDeclarationDocument(), effective: false }; + } + return { document: structuredClone(document), effective: true }; +} diff --git a/packages/coding-agent/src/core/mcp/mcp-redaction.ts b/packages/coding-agent/src/core/mcp/mcp-redaction.ts new file mode 100644 index 000000000..9f0b38d34 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-redaction.ts @@ -0,0 +1,35 @@ +import type { McpDeclaration, McpDeclarationDocument } from "./mcp-declarations.js"; + +const SENSITIVE_KEY = /(?:authorization|credential|secret|token|password|api[_-]?key|cookie|header)/i; + +/** + * Redact arbitrary persisted MCP-shaped data before rendering it. This is + * intentionally defensive even though M01 declarations reject such fields. + */ +export function redactMcpValue(value: unknown, key = ""): unknown { + if (SENSITIVE_KEY.test(key)) return ""; + if (typeof value === "string") { + if (key === "url") { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash) return ""; + } catch { + return ""; + } + } + return value; + } + if (Array.isArray(value)) return value.map((entry) => redactMcpValue(entry)); + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, redactMcpValue(child, childKey)])); + } + return value; +} + +export function redactMcpDeclaration(declaration: McpDeclaration): McpDeclaration { + return structuredClone(redactMcpValue(declaration) as McpDeclaration); +} + +export function redactMcpDeclarationDocument(document: McpDeclarationDocument): McpDeclarationDocument { + return structuredClone(redactMcpValue(document) as McpDeclarationDocument); +} diff --git a/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts b/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts new file mode 100644 index 000000000..8f368f36f --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { parseMcpDeclarationDocument, type McpDeclaration, type McpDeclarationDocument } from "./mcp-declarations.js"; +import { + type ProjectMcpDeclarationAdmission, + validateProjectMcpDeclarationAdmission, +} from "./mcp-project-trust.js"; + +export type McpRuntimeDeclarationSource = "user" | "project"; + +/** A declaration-only record. No credential, auth, transport, or launch state is admitted. */ +export interface McpRuntimeDeclaration { + readonly name: string; + readonly endpoint: string; + readonly enabled: boolean; + readonly source: McpRuntimeDeclarationSource; +} + +/** + * An immutable decision detached from settings and raw project paths. The + * revision covers the complete ordered selection, including disabled entries. + */ +export interface McpRuntimeDeclarationSnapshot { + readonly revision: string; + readonly declarations: Readonly>; +} + +export interface CreateMcpRuntimeDeclarationSnapshotInput { + /** Already-read user/global declarations. They are parsed before selection. */ + readonly userDocument?: unknown; + /** Missing, forged, stale, or foreign admissions are fail-closed. */ + readonly projectAdmission?: ProjectMcpDeclarationAdmission; + /** Never invoked unless the opaque admission validates first. */ + readonly readProjectDocument?: () => unknown; +} + +function compareCodePoints(left: string, right: string): number { + let leftOffset = 0; + let rightOffset = 0; + while (leftOffset < left.length && rightOffset < right.length) { + const leftPoint = left.codePointAt(leftOffset)!; + const rightPoint = right.codePointAt(rightOffset)!; + if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1; + leftOffset += leftPoint > 0xffff ? 2 : 1; + rightOffset += rightPoint > 0xffff ? 2 : 1; + } + return leftOffset === left.length && rightOffset === right.length ? 0 : leftOffset === left.length ? -1 : 1; +} + +function compareNames(left: McpRuntimeDeclaration, right: McpRuntimeDeclaration): number { + return compareCodePoints(left.name, right.name); +} + +function freezeDeclaration(declaration: McpRuntimeDeclaration): McpRuntimeDeclaration { + return Object.freeze({ + name: declaration.name, + endpoint: declaration.endpoint, + enabled: declaration.enabled, + source: declaration.source, + }); +} + +function parseDocument(value: unknown, source: McpRuntimeDeclarationSource): McpRuntimeDeclaration[] { + // Reuse the M01 parser: it rejects own accessors, inherited fields, symbols, + // exotic prototypes, duplicate endpoints, and malformed URL shapes. + const document: McpDeclarationDocument = parseMcpDeclarationDocument(value); + const declarations: McpRuntimeDeclaration[] = []; + for (const name of Object.getOwnPropertyNames(document.servers)) { + const declaration: McpDeclaration = document.servers[name]!; + declarations.push({ name: declaration.name, endpoint: declaration.url, enabled: declaration.enabled, source }); + } + return declarations.sort(compareNames); +} + +function snapshotRevision(declarations: readonly McpRuntimeDeclaration[]): string { + const canonical = declarations.map(({ name, endpoint, enabled, source }) => [name, endpoint, enabled, source]); + return createHash("sha256").update(JSON.stringify([1, canonical])).digest("hex"); +} + +/** + * Select global declarations first. A name or endpoint collision makes the + * complete project contribution inert, preventing partial shadow-dependent + * configuration. Both selection and resulting records are frozen snapshots. + */ +export function createMcpRuntimeDeclarationSnapshot( + input: CreateMcpRuntimeDeclarationSnapshotInput = {}, +): McpRuntimeDeclarationSnapshot { + const user = parseDocument(input.userDocument, "user"); + const selected = [...user]; + const userNames = new Set(user.map((declaration) => declaration.name)); + const userEndpoints = new Set(user.map((declaration) => declaration.endpoint)); + + if ( + input.readProjectDocument && + validateProjectMcpDeclarationAdmission(input.projectAdmission).kind === "granted" + ) { + const project = parseDocument(input.readProjectDocument(), "project"); + if (!project.some((declaration) => userNames.has(declaration.name) || userEndpoints.has(declaration.endpoint))) { + selected.push(...project); + } + } + + selected.sort(compareNames); + const declarations = Object.create(null) as Record; + for (const declaration of selected) { + Object.defineProperty(declarations, declaration.name, { + value: freezeDeclaration(declaration), enumerable: true, configurable: false, writable: false, + }); + } + return Object.freeze({ revision: snapshotRevision(selected), declarations: Object.freeze(declarations) }); +} diff --git a/packages/coding-agent/src/core/mcp/project-trust-authority.ts b/packages/coding-agent/src/core/mcp/project-trust-authority.ts new file mode 100644 index 000000000..0f3fecdd9 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/project-trust-authority.ts @@ -0,0 +1,171 @@ +import { createHash } from "node:crypto"; +import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +/** + * Explicit policy input from a global, user-owned authority. Project settings + * are deliberately not an input to this factory or to the returned authority. + */ +export interface McpProjectTrustAuthorityInput { + /** Caller-owned policy revision, captured with the allowlist before use. */ + readonly revision: string; + /** User-approved project directories. They must be exact canonical directories. */ + readonly allowedProjectDirectories: readonly string[]; +} + +/** An opaque grant bound to a single project authority snapshot. */ +declare const mcpProjectTrustBindingBrand: unique symbol; +export interface McpProjectTrustBinding { + readonly [mcpProjectTrustBindingBrand]: never; +} + +export type McpProjectTrustAuthorization = + | { readonly kind: "denied" } + | { readonly kind: "granted"; readonly binding: McpProjectTrustBinding }; + +/** Safe, opaque verification result for a privileged boundary. */ +export type McpProjectTrustBindingValidation = { readonly kind: "denied" } | { readonly kind: "granted" }; + +/** + * A project trust authority exposes no policy, path, digest, revision, or + * boolean authorization surface. A revision is factory input only; consumers + * retain a grant and may only ask whether it is still valid. + */ +export interface McpProjectTrustAuthority { + authorizeProjectDirectory(projectDirectory: string): McpProjectTrustAuthorization; + validateBinding(binding: unknown): McpProjectTrustBindingValidation; +} + +interface DirectoryIdentity { + readonly canonicalPath: string; + readonly device: string; + readonly inode: string; +} + +interface BindingRecord { + readonly revision: string; + readonly digest: string; + readonly identity: DirectoryIdentity; +} + +const DENIED: McpProjectTrustAuthorization = Object.freeze({ kind: "denied" }); +const BINDING_DENIED: McpProjectTrustBindingValidation = Object.freeze({ kind: "denied" }); +const BINDING_GRANTED: McpProjectTrustBindingValidation = Object.freeze({ kind: "granted" }); + +// Only authorities minted by this Core factory may cross privileged MCP seams. +// The registry remains module-private; callers receive only this narrow check. +const genuineAuthorities = new WeakSet(); + +export function isMcpProjectTrustAuthority(value: unknown): value is McpProjectTrustAuthority { + return typeof value === "object" && value !== null && genuineAuthorities.has(value); +} + +/** + * Reads a directory only when the supplied spelling is already its exact + * physical spelling. Relative paths, lexical aliases, symlinks (including + * ancestor symlinks), unreadable paths, and non-directories all fail closed. + */ +function exactDirectoryIdentity(path: string): DirectoryIdentity | undefined { + if (!isAbsolute(path) || resolve(path) !== path) { + return undefined; + } + + try { + const initial = lstatSync(path); + if (initial.isSymbolicLink() || !initial.isDirectory()) { + return undefined; + } + accessSync(path, constants.R_OK | constants.X_OK); + const canonicalPath = realpathSync.native(path); + if (canonicalPath !== path) { + return undefined; + } + const canonical = statSync(canonicalPath, { bigint: true }); + if (!canonical.isDirectory()) { + return undefined; + } + accessSync(canonicalPath, constants.R_OK | constants.X_OK); + return { + canonicalPath, + device: canonical.dev.toString(), + inode: canonical.ino.toString(), + }; + } catch { + return undefined; + } +} + +function digestSnapshot(revision: string, directories: readonly DirectoryIdentity[]): string { + return createHash("sha256") + .update(revision) + .update("\0") + .update(directories.map(({ canonicalPath, device, inode }) => `${canonicalPath}\0${device}\0${inode}`).join("\0")) + .digest("hex"); +} + +function sameIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return left.canonicalPath === right.canonicalPath && left.device === right.device && left.inode === right.inode; +} + +/** + * Snapshots a global/user-owned allowlist before an MCP use. Construction is + * read-only and invalidates the complete policy on malformed, missing, + * unreadable, symlinked, or canonical-alias entries. No runtime settings, + * secrets, network, startup state, or ambient trust are consulted. + */ +export function createMcpProjectTrustAuthority(input: McpProjectTrustAuthorityInput): McpProjectTrustAuthority { + const revision = typeof input.revision === "string" ? input.revision : ""; + const requestedDirectories = Array.isArray(input.allowedProjectDirectories) + ? [...input.allowedProjectDirectories] + : []; + const identities = requestedDirectories.map((directory) => + typeof directory === "string" ? exactDirectoryIdentity(directory) : undefined, + ); + const valid = + revision.length > 0 && + identities.every((identity): identity is DirectoryIdentity => identity !== undefined) && + new Set(identities.map((identity) => identity.canonicalPath)).size === identities.length; + const snapshot = valid ? Object.freeze([...identities]) : Object.freeze([] as DirectoryIdentity[]); + const snapshotDigest = digestSnapshot(revision, snapshot); + const bindings = new WeakSet(); + const records = new WeakMap(); + const authority: McpProjectTrustAuthority = Object.freeze({ + authorizeProjectDirectory(projectDirectory: string): McpProjectTrustAuthorization { + const requested = typeof projectDirectory === "string" ? exactDirectoryIdentity(projectDirectory) : undefined; + if (!requested || !snapshot.some((approved) => sameIdentity(approved, requested))) { + return DENIED; + } + + // Module-private brands and records make this opaque grant runtime-unforgeable. + const binding = Object.freeze(Object.create(null)) as McpProjectTrustBinding; + bindings.add(binding); + records.set(binding, Object.freeze({ revision, digest: snapshotDigest, identity: requested })); + return Object.freeze({ kind: "granted", binding }); + }, + validateBinding(binding: unknown): McpProjectTrustBindingValidation { + if (typeof binding !== "object" || binding === null || !bindings.has(binding)) { + return BINDING_DENIED; + } + const record = records.get(binding); + const currentSnapshot = snapshot.map(({ canonicalPath }) => exactDirectoryIdentity(canonicalPath)); + if (currentSnapshot.some((identity) => identity === undefined)) { + return BINDING_DENIED; + } + const currentIdentities = currentSnapshot as DirectoryIdentity[]; + if ( + !record || + record.revision !== revision || + record.digest !== snapshotDigest || + !currentIdentities.every((identity, index) => sameIdentity(snapshot[index], identity)) || + digestSnapshot(revision, currentIdentities) !== snapshotDigest || + !snapshot.some((approved) => sameIdentity(approved, record.identity)) + ) { + return BINDING_DENIED; + } + + return BINDING_GRANTED; + }, + }); + genuineAuthorities.add(authority); + return authority; +} diff --git a/packages/coding-agent/src/core/rlm-runtime.ts b/packages/coding-agent/src/core/rlm-runtime.ts index e89472fce..6690ea3ab 100644 --- a/packages/coding-agent/src/core/rlm-runtime.ts +++ b/packages/coding-agent/src/core/rlm-runtime.ts @@ -2,7 +2,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Api, Model, ServiceTier } from "@earendil-works/pi-ai"; import type { AgentSession } from "./agent-session.js"; import type { ToolDefinition } from "./extensions/index.js"; -import type { HostRequestHandler } from "./kernel/index.js"; +import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "./kernel/index.js"; export interface RlmRunRequest { prompt: string; @@ -150,7 +150,7 @@ export function findRlmModelMatches(query: string, models: Model[], limit: /** Adapt an RlmRunHandler into the typed "rlm.run" handler for the kernel host bridge. */ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload: Record, _context: HostRequestContext) => { if (typeof payload.prompt !== "string") { throw new Error("rlm.run prompt must be a string"); } @@ -162,12 +162,12 @@ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHand cellSourceCode, }); return result as unknown as Record; - }; + }); } /** Search a bounded authenticated model catalog without adding it to the system prompt. */ export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload: Record, _context: HostRequestContext) => { if (typeof payload.query !== "string") { throw new Error("rlm.find_models query must be a string"); } @@ -176,26 +176,26 @@ export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): H throw new Error(`rlm.find_models limit must be an integer from 1 to ${MAX_RLM_MODEL_SEARCH_LIMIT}`); } return { models: (await handler(payload.query, limit as number)).models }; - }; + }); } /** Expose the current parent session's RLM child registry to its kernel. */ export function createRlmListSubagentsHostHandler(handler: RlmListSubagentsHandler): HostRequestHandler { - return async () => { + return createHostRequestHandler(async (_payload: Record, _context: HostRequestContext) => { const { subagents } = await handler(); return { subagents }; - }; + }); } /** Delete one direct child selected from the current parent session's registry. */ export function createRlmDeleteSubagentHostHandler(handler: RlmDeleteSubagentHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload: Record, _context: HostRequestContext) => { if (typeof payload.target !== "string" || !payload.target.trim()) { throw new Error("rlm.delete_subagent target must be a non-empty string"); } const { subagent, outcome } = await handler(payload.target.trim()); return outcome === undefined ? { subagent } : { subagent, outcome }; - }; + }); } export interface RlmSubagentRuntime { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ab42f5e93..e9dec5663 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -4,6 +4,11 @@ import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { + parseMcpDeclarationDocument, + type McpDeclarationDocument, + type McpDeclarationScope, +} from "./mcp/mcp-declarations.js"; const RECENT_MODELS_LIMIT = 20; export const DEFAULT_IDLE_EVICTION_MINUTES = 90; @@ -119,6 +124,12 @@ export type McpServerConfig = disabledTools?: string[]; }; +/** Global-only Core policy snapshot for M01 project MCP admission. */ +export interface McpProjectTrustPolicy { + revision: string; + allowedProjectDirectories: string[]; +} + export interface Settings { onboardingShown?: boolean; onboardingCompleted?: boolean; @@ -144,7 +155,11 @@ export interface Settings { quietStartup?: boolean; shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) - mcpServers?: Record; // User-declared MCP servers (name → config); built-ins are in the ai/mcp catalog + mcpServers?: Record; // Legacy runtime-owned MCP integrations; M01 does not read or write this field. + /** M01 credential-free MCP declarations. Project declarations remain inert without Core trust. */ + mcpDeclarations?: McpDeclarationDocument; + /** Global-only M01 project policy; public composition ignores project-local copies. */ + mcpProjectTrustPolicy?: McpProjectTrustPolicy; packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) extensions?: string[]; // Array of local extension file paths or directories skills?: string[]; // Array of local skill file paths or directories @@ -333,12 +348,25 @@ export class SettingsManager { this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } - /** Create a SettingsManager that loads from files */ + /** Create a SettingsManager that loads both global and project settings. */ static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { const storage = new FileSettingsStorage(cwd, agentDir); return SettingsManager.fromStorage(storage); } + /** + * Read only the global settings scope. Project MCP admission uses this before + * it is allowed to open project settings, so no project storage is touched. + */ + static loadGlobalSettings(cwd: string, agentDir: string = getAgentDir()): Settings { + return SettingsManager.loadGlobalSettingsFromStorage(new FileSettingsStorage(cwd, agentDir)); + } + + /** Storage-level form for bounded callers and tests; it never reads project scope. */ + static loadGlobalSettingsFromStorage(storage: SettingsStorage): Settings { + return SettingsManager.tryLoadFromStorage(storage, "global").settings; + } + /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage): SettingsManager { const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); @@ -1213,6 +1241,30 @@ export class SettingsManager { return this.settings.mcpServers; } + /** Read one M01 declaration document without merging user and project scope. */ + getMcpDeclarationDocument(scope: McpDeclarationScope): McpDeclarationDocument { + const settings = scope === "user" ? this.globalSettings : this.projectSettings; + return parseMcpDeclarationDocument(settings.mcpDeclarations); + } + + /** + * Persist a parsed M01 declaration document in exactly one settings scope. + * This never writes mcpServers, auth storage, or any credential-shaped value. + */ + setMcpDeclarationDocument(scope: McpDeclarationScope, document: McpDeclarationDocument): void { + const parsed = parseMcpDeclarationDocument(document); + if (scope === "user") { + this.globalSettings.mcpDeclarations = structuredClone(parsed); + this.markModified("mcpDeclarations"); + this.save(); + return; + } + const projectSettings = structuredClone(this.projectSettings); + projectSettings.mcpDeclarations = structuredClone(parsed); + this.markProjectModified("mcpDeclarations"); + this.saveProjectSettings(projectSettings); + } + setEnabledModels(patterns: string[] | undefined): void { this.globalSettings.enabledModels = patterns; this.markModified("enabledModels"); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 093da58de..b434d28e0 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -137,6 +137,14 @@ export { } from "./core/extensions/index.js"; // Footer data provider (git branch + extension statuses - data not otherwise available to extensions) export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; +export { + createMcpProjectTrustAuthority, + type McpProjectTrustAuthority, + type McpProjectTrustAuthorityInput, + type McpProjectTrustAuthorization, + type McpProjectTrustBinding, + type McpProjectTrustBindingValidation, +} from "./core/mcp/project-trust-authority.js"; export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; export type { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6315d0ad7..909d36a65 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -193,6 +193,15 @@ function toPrintOutputMode(appMode: AppMode): Exclude { const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), { closeClientOnDispose: true, sendClientEnv: true, - ownedSession: options.clientOwned, + ownedSession: clientOwned, supportsExtensionUi: options.supportsExtensionUi, recoverDaemon: () => ensureInteractiveDaemonRunning(options.socketPath), telemetryDisabled: options.config.telemetryDisabled, @@ -972,7 +983,7 @@ async function createDaemonClientConnection(options: { return await attach(summary); } - if (options.sessionPath && !options.clientOwned) { + if (options.sessionPath && !clientOwned) { const activeSummary = findActiveDaemonSessionSummaryForSessionFile( await listActiveDaemonSessionSummaries(client), options.sessionPath, @@ -981,8 +992,7 @@ async function createDaemonClientConnection(options: { return await attach(activeSummary); } } - if (options.clientOwned) { - await client.waitForHello(); + if (clientOwned) { if (!client.supportsServerCapability("client_owned_sessions")) { throw new DaemonCapabilityUnavailableError("create", "client_owned_sessions"); } @@ -995,8 +1005,13 @@ async function createDaemonClientConnection(options: { continueRecent: options.continueRecent, noSession: options.noSession, env: collectDaemonClientEnv(), - lifecycle: options.clientOwned ? "client_owned" : "resident", - launchEnv: options.clientOwned ? collectDaemonLaunchEnv() : undefined, + lifecycle: clientOwned ? "client_owned" : "resident", + // Forward the caller's environment for BOTH lifecycles. A resident + // worker still has to be launched with the caller's env: an embedder + // such as the verifiers ACP harness passes the model endpoint, its + // bearer token, and proxy settings that way, and a worker started + // without them cannot reach the model at all. + launchEnv: collectDaemonLaunchEnv(), }); if (!response.success) { throw deserializeDaemonError(response); @@ -1530,7 +1545,8 @@ export async function main(args: string[], options?: MainOptions) { config: defaultSessionConfig, sessionPath: parsed.noSession ? undefined : sessionManager.getSessionFile(), continueRecent: parsed.continue, - clientOwned: true, + // A no-session ACP invocation has nothing to reattach to; complete its worker on disconnect. + clientOwned: isClientOwnedDaemonSession(appMode, parsed.noSession), noSession: parsed.noSession, supportsExtensionUi: appMode === "rpc", })); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3443cd3f8..09e6412da 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -404,6 +404,21 @@ type PassiveRlmSubagent = PassiveRlmRoot & { chain: PersistedRlmSubagentRegistryEntry[]; }; +type AgentFamilyCatalogSource = "saved" | "passive" | "resident"; + +/** + * The public catalog has to retain a usable depth for legacy callers, while + * authorization must distinguish a persisted claim from a depth inferred by a + * legacy reader. These claim fields are deliberately private to catalog + * construction and never escape into agent-messages' public roster. + */ +type AgentFamilyCatalogCandidate = AgentFamilyCatalogEntry & { + source: AgentFamilyCatalogSource; + depthClaim?: number; + parentSessionIdClaim?: string; + parentSessionPathClaim?: string; +}; + class RuntimeOpenCancelledError extends Error {} class BoundSessionUnavailableError extends Error {} @@ -2860,18 +2875,30 @@ export class AgentDaemon { } private async createAgentObserveListResult(currentState: ActiveSessionState): Promise { + const catalog = await this.agentFamilyCatalogEntries(); + this.authoritativeAgentFamilyEntry(currentState, catalog); const agents = this.listTargetableSessionStates(currentState) - .filter( - (state) => - state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state), - ) - .map((state) => this.createAgentObserveSummary(state, currentState)); + .filter((state) => { + if (state.activeSessionId === currentState.activeSessionId) return true; + try { + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(state, catalog), + catalog, + ); + return true; + } catch (error) { + if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; + throw error; + } + }) + .map((state) => this.createAgentObserveSummary(state, currentState, catalog)); const residentIds = new Set(agents.map((agent) => agent.activeSessionId)); for (const passive of await this.listPassiveRlmSubagents()) { if (residentIds.has(passive.info.id)) continue; try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) continue; throw error; @@ -2901,7 +2928,7 @@ export class AgentDaemon { residentIds.add(passive.info.id); } return { - current: this.createAgentObserveSummary(currentState, currentState), + current: this.createAgentObserveSummary(currentState, currentState, catalog), agents, }; } @@ -2910,10 +2937,12 @@ export class AgentDaemon { currentState: ActiveSessionState, target: string, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); + // Hydration may mutate live endpoint fields. Re-authorize and label only from the + // captured catalog that authorized the wake, never from a post-hydration rescan. + this.assertAgentFamilyReachable(currentState, targetState, catalog); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), }; } @@ -2921,14 +2950,15 @@ export class AgentDaemon { currentState: ActiveSessionState, input: AgentObserveRecentMessagesInput, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); + // See getAgent: an observation must use its original authorization snapshot. + this.assertAgentFamilyReachable(currentState, targetState, catalog); const limit = normalizeObserveLimit(input.limit); const maxChars = normalizeObserveMaxChars(input.maxChars); const messages = targetState.runtime.session.messages; const startIndex = Math.max(0, messages.length - limit); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), messages: messages .slice(startIndex) .map((message, offset) => createAgentObserveMessagePreview(message, startIndex + offset, maxChars)), @@ -2941,8 +2971,17 @@ export class AgentDaemon { private createAgentObserveSummary( state: ActiveSessionState, currentState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], ): AgentObserveAgentSummary { const summary = summaryForActiveSession(state); + // The catalog is the authorization snapshot, so relationship fields must not + // be re-derived from a runtime that may have changed while a passive target woke. + const topology = this.authoritativeAgentFamilyEntry(state, catalog); + const parentState = topology.parentSessionId + ? [...this.sessions.values()].find( + (candidate) => candidate.runtime.session.sessionId === topology.parentSessionId, + ) + : undefined; const session = state.runtime.session; const messages = session.messages; const latest = messages.at(-1); @@ -2971,8 +3010,8 @@ export class AgentDaemon { messageCount: summary.messageCount, queuedCount: summary.sessionActions.queuedCount, isSessionActive: summary.isSessionActive, - ...(summary.parentActiveSessionId ? { parentActiveSessionId: summary.parentActiveSessionId } : {}), - ...(summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), + ...(parentState ? { parentActiveSessionId: parentState.activeSessionId } : {}), + ...(topology.parentSessionId ? { parentSessionId: topology.parentSessionId } : {}), ...(summary.rlmChildId ? { rlmChildId: summary.rlmChildId } : {}), ...(summary.rlmParentNodeId ? { rlmParentNodeId: summary.rlmParentNodeId } : {}), ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), @@ -5036,8 +5075,7 @@ export class AgentDaemon { private async createAgentFamilyRoster(currentState: ActiveSessionState): Promise { const catalog = await this.createAgentFamilyCatalog(currentState); - const current = catalog.find((entry) => entry.id === currentState.runtime.session.sessionId); - if (!current) throw new Error("Current agent is missing from the family catalog"); + const current = this.authoritativeAgentFamilyEntry(currentState, catalog); return buildAgentFamilyRoster(current, catalog); } @@ -5255,54 +5293,203 @@ export class AgentDaemon { }; } - private passiveAgentFamilyEntry(passive: PassiveRlmSubagent): AgentFamilyCatalogEntry { - const entry = passive.entry; - const depth = passive.info.rlmDepth ?? entry.rlmDepth ?? passive.chain.length; - const parentSessionPath = - depth > 0 - ? (entry.parentSessionFile ?? - passive.chain.at(-2)?.sessionFile ?? - passive.rootParentState?.runtime.session.sessionFile ?? - passive.rootInfo?.path) + /** Capture persisted topology once for an authorization decision. */ + private async agentFamilyCatalogEntries(): Promise { + const saved = await SessionManager.listAll(undefined, this.options.defaultSessionConfig.sessionDir); + const entries: AgentFamilyCatalogCandidate[] = await Promise.all( + saved.map((info) => this.savedAgentFamilyCandidate(info)), + ); + // Artifact-resident descendants are absent from the saved-session scan. + for (const passive of await this.listPassiveRlmSubagents(saved, true)) { + entries.push(await this.passiveAgentFamilyCandidate(passive)); + } + for (const state of this.sessions.values()) entries.push(this.residentAgentFamilyCandidate(state)); + return Object.freeze(this.mergeEquivalentAgentFamilyCatalogEntries(entries)); + } + + /** The header is the only durable evidence that a saved depth/path was explicit. */ + private async persistedTopologyClaims(sessionPath: string): Promise<{ depth?: number; parentSessionPath?: string }> { + try { + const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]; + if (!firstLine) return {}; + const header = JSON.parse(firstLine) as { rlmDepth?: unknown; parentSession?: unknown }; + const depth = + typeof header.rlmDepth === "number" && Number.isSafeInteger(header.rlmDepth) && header.rlmDepth >= 0 + ? header.rlmDepth + : undefined; + const parentSessionPath = + typeof header.parentSession === "string" && header.parentSession + ? canonicalSessionPath( + isAbsolute(header.parentSession) + ? header.parentSession + : resolve(dirname(sessionPath), header.parentSession), + ) + : undefined; + return { ...(depth !== undefined ? { depth } : {}), ...(parentSessionPath ? { parentSessionPath } : {}) }; + } catch { + return {}; + } + } + + private async savedAgentFamilyCandidate(info: SessionInfo): Promise { + const claims = await this.persistedTopologyClaims(info.path); + return { + id: info.id, + ...(info.name ? { name: info.name } : {}), + // resolveSessionRlmDepth is retained only as a usable legacy fallback. + // It is deliberately not a claim and therefore cannot contradict an overlay. + depth: info.rlmDepth, + status: "inactive", + sessionPath: canonicalSessionPath(info.path), + source: "saved", + ...(claims.depth !== undefined ? { depthClaim: claims.depth } : {}), + ...(claims.parentSessionPath + ? { parentSessionPath: claims.parentSessionPath, parentSessionPathClaim: claims.parentSessionPath } + : {}), + }; + } + + private residentAgentFamilyCandidate(state: ActiveSessionState): AgentFamilyCatalogCandidate { + const entry = this.agentFamilyEntry(state); + const session = state.runtime.session; + const metadata = state.runtime.metadata; + const headerParent = this.resolveHeaderParentSessionPath(state); + const parentSessionPath = headerParent ?? metadata.parentSessionFile; + const depthClaim = + typeof session.rlmDepth === "number" && Number.isSafeInteger(session.rlmDepth) && session.rlmDepth >= 0 + ? session.rlmDepth : undefined; + return { + ...entry, + source: "resident", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(metadata.parentSessionId + ? { parentSessionId: metadata.parentSessionId, parentSessionIdClaim: metadata.parentSessionId } + : {}), + ...(parentSessionPath + ? { + parentSessionPath: canonicalSessionPath(parentSessionPath), + parentSessionPathClaim: canonicalSessionPath(parentSessionPath), + } + : {}), + }; + } + + /** + * Merge a durable row with a passive/resident view only if all *present* + * topology claims agree. In particular, the depth produced for legacy saved + * files is a fallback, not evidence against a newer explicit overlay. + */ + private mergeEquivalentAgentFamilyCatalogEntries( + entries: readonly AgentFamilyCatalogCandidate[], + ): AgentFamilyCatalogEntry[] { + const canonical = entries.map((entry) => ({ + ...entry, + ...(entry.sessionPath ? { sessionPath: canonicalSessionPath(entry.sessionPath) } : {}), + ...(entry.parentSessionPath ? { parentSessionPath: canonicalSessionPath(entry.parentSessionPath) } : {}), + ...(entry.parentSessionPathClaim + ? { parentSessionPathClaim: canonicalSessionPath(entry.parentSessionPathClaim) } + : {}), + })); + const compatible = (left: AgentFamilyCatalogCandidate, right: AgentFamilyCatalogCandidate) => + left.id === right.id && + left.sessionPath === right.sessionPath && + (left.depthClaim === undefined || right.depthClaim === undefined || left.depthClaim === right.depthClaim) && + (left.parentSessionPathClaim === undefined || + right.parentSessionPathClaim === undefined || + left.parentSessionPathClaim === right.parentSessionPathClaim) && + (left.parentSessionIdClaim === undefined || + right.parentSessionIdClaim === undefined || + left.parentSessionIdClaim === right.parentSessionIdClaim); + const groups: AgentFamilyCatalogCandidate[][] = []; + for (const entry of canonical) { + const group = groups.find((candidate) => candidate.every((member) => compatible(member, entry))); + if (group) group.push(entry); + else groups.push([entry]); + } + const sourceRank: Record = { saved: 3, passive: 2, resident: 1 }; + const statusRank: Record = { inactive: 0, idle: 1, running: 2 }; + const stable = (values: readonly (string | undefined)[]) => + values.filter((value): value is string => value !== undefined).sort()[0]; + const preferred = ( + rows: readonly AgentFamilyCatalogCandidate[], + get: (row: AgentFamilyCatalogCandidate) => T | undefined, + ) => + [...rows] + .sort((left, right) => sourceRank[right.source] - sourceRank[left.source]) + .map(get) + .find((value): value is T => value !== undefined); + return groups.map((rows) => { + const status = rows.reduce( + (best, row) => (statusRank[row.status] > statusRank[best] ? row.status : best), + "inactive", + ); + const depth = preferred(rows, (row) => row.depthClaim) ?? preferred(rows, (row) => row.depth)!; + const parentSessionId = preferred(rows, (row) => row.parentSessionIdClaim); + const parentSessionPath = preferred(rows, (row) => row.parentSessionPathClaim); + return { + id: rows[0]!.id, + depth, + status, + ...(stable(rows.map((row) => row.name)) ? { name: stable(rows.map((row) => row.name)) } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath } : {}), + ...(rows[0]!.sessionPath ? { sessionPath: rows[0]!.sessionPath } : {}), + }; + }); + } + + private async passiveAgentFamilyCandidate(passive: PassiveRlmSubagent): Promise { + const entry = passive.entry; + const claims = await this.persistedTopologyClaims(entry.sessionFile); + const registryParentPath = entry.parentSessionFile ? canonicalSessionPath(entry.parentSessionFile) : undefined; + const parentSessionPath = claims.parentSessionPath ?? registryParentPath; + const depthClaim = claims.depth ?? entry.rlmDepth; return { id: passive.info.id, - name: passive.info.name ?? entry.sessionName, - depth, + ...((passive.info.name ?? entry.sessionName) ? { name: passive.info.name ?? entry.sessionName } : {}), + depth: depthClaim ?? passive.info.rlmDepth, status: "idle", - ...(depth > 0 && entry.parentSessionId ? { parentSessionId: entry.parentSessionId } : {}), - ...(parentSessionPath ? { parentSessionPath: canonicalSessionPath(parentSessionPath) } : {}), sessionPath: canonicalSessionPath(entry.sessionFile), + source: "passive", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(entry.parentSessionId + ? { parentSessionId: entry.parentSessionId, parentSessionIdClaim: entry.parentSessionId } + : {}), + ...(parentSessionPath ? { parentSessionPath, parentSessionPathClaim: parentSessionPath } : {}), }; } private async getOrHydrateAuthorizedAgentFamilyTarget( currentState: ActiveSessionState, target: string, - ): Promise { + ): Promise<{ targetState: ActiveSessionState; catalog: readonly AgentFamilyCatalogEntry[] }> { + const catalog = await this.agentFamilyCatalogEntries(); try { - return this.getBoundSessionState(target); + return { targetState: this.getBoundSessionState(target), catalog }; } catch (error) { if (error instanceof BoundSessionUnavailableError) { const targetState = this.getSessionState(target); - this.assertAgentFamilyReachable(currentState, targetState); - return this.getOrHydrateBoundSessionState(target); + this.assertAgentFamilyReachable(currentState, targetState, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; } if (error instanceof AmbiguousActiveSessionError) { - const targetState = this.resolveAgentFamilySessionName(currentState, target, error); - return this.getOrHydrateBoundSessionState(targetState.activeSessionId); + const targetState = this.resolveAgentFamilySessionName(currentState, target, error, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(targetState.activeSessionId), catalog }; } } const passive = await this.findPassiveRlmSubagent(target); - if (!passive) return this.getOrHydrateBoundSessionState(target); - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); - return this.hydratePassiveRlmSubagent(passive); + if (!passive) return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); + return { targetState: await this.hydratePassiveRlmSubagent(passive), catalog }; } private resolveAgentFamilySessionName( currentState: ActiveSessionState, target: string, ambiguity: AmbiguousActiveSessionError, + catalog: readonly AgentFamilyCatalogEntry[], ): ActiveSessionState { const reachableMatches = new Map( [...this.sessions.values()] @@ -5311,7 +5498,7 @@ export class AgentDaemon { return ( (session.sessionId === target || session.sessionName === target) && (state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state)) + this.isAgentFamilyReachable(currentState, state, catalog)) ); }) .map((state) => [state.activeSessionId, state]), @@ -5321,9 +5508,36 @@ export class AgentDaemon { return matches[0]!; } - private isAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): boolean { + private authoritativeAgentFamilyEntry( + state: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + return this.authoritativeAgentFamilyEntryForSessionId(state.runtime.session.sessionId, catalog); + } + + private authoritativeAgentFamilyEntryForSessionId( + sessionId: string, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + const entries = catalog.filter((candidate) => candidate.id === sessionId); + if (entries.length !== 1) throw new Error(AGENT_FAMILY_REACH_ERROR); + return entries[0]!; + } + + private isAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): boolean { try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); return true; } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; @@ -5331,17 +5545,36 @@ export class AgentDaemon { } } - private assertAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): void { + private assertAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): void { if (currentState.activeSessionId === targetState.activeSessionId) return; - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); } private agentMessageRelationship( fromState: ActiveSessionState | undefined, targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(targetState), + ...(fromState ? [this.agentFamilyEntry(fromState)] : []), + ], ): AgentFamilyRelationship | undefined { if (!fromState) return undefined; - return agentFamilyRelationship(this.agentFamilyEntry(targetState), this.agentFamilyEntry(fromState)); + return agentFamilyRelationship( + this.authoritativeAgentFamilyEntry(targetState, catalog), + this.authoritativeAgentFamilyEntry(fromState, catalog), + catalog, + ); } private async sendAgentSessionMessage(options: { @@ -5358,27 +5591,35 @@ export class AgentDaemon { } const targetSelector = assertDirectAgentMessageTarget(options.targetSelector); const message = normalizeAgentSessionMessage(options.message, DEFAULT_AGENT_MESSAGE_MAX_CHARS); + // Use one immutable persisted topology through selector resolution, wake, and delivery. + const catalog = + options.origin === "agent" && options.fromState ? await this.agentFamilyCatalogEntries() : undefined; let targetState: ActiveSessionState; try { targetState = this.getBoundSessionState(targetSelector); } catch (error) { if (error instanceof BoundSessionUnavailableError) { if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, this.getSessionState(targetSelector)); + this.assertAgentFamilyReachable(options.fromState, this.getSessionState(targetSelector), catalog!); } targetState = await this.getOrHydrateBoundSessionState(targetSelector); } else { if (error instanceof AmbiguousActiveSessionError) { if (options.origin !== "agent" || !options.fromState) throw error; - const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error); + const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error, catalog!); targetState = await this.getOrHydrateBoundSessionState(resolved.activeSessionId); } else { const passiveSubagent = await this.findPassiveRlmSubagent(targetSelector); if (passiveSubagent) { if (options.origin === "agent" && options.fromState) { + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId( + passiveSubagent.info.id, + catalog!, + ); assertAgentFamilyReach( - this.agentFamilyEntry(options.fromState), - this.passiveAgentFamilyEntry(passiveSubagent), + this.authoritativeAgentFamilyEntry(options.fromState, catalog!), + passiveEntry, + catalog!, ); } targetState = await this.hydratePassiveRlmSubagent(passiveSubagent); @@ -5405,7 +5646,7 @@ export class AgentDaemon { throw new Error("Agent messaging cannot target the sending session"); } if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, targetState); + this.assertAgentFamilyReachable(options.fromState, targetState, catalog!); } const releaseQueueSlot = this.reserveAgentMessageQueueSlot(targetState); const senderKey = @@ -5423,7 +5664,7 @@ export class AgentDaemon { from: options.sender ?? this.createAgentSessionMessageSender(options.fromState, options.clientId ?? options.origin), - fromRelationship: this.agentMessageRelationship(options.fromState, targetState), + fromRelationship: this.agentMessageRelationship(options.fromState, targetState, catalog ?? []), target: this.createAgentSessionMessageEndpoint(targetState), }; try { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc..8289d50b1 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -1,5 +1,6 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, ServiceTier, TextContent, Transport } from "@earendil-works/pi-ai"; +import { ENV_AGENT_DIR } from "../../config.js"; import type { AgentSessionMessageDeliveryMode, AgentSessionMessageReceipt, @@ -203,6 +204,58 @@ export function collectDaemonClientEnv(source: NodeJS.ProcessEnv = process.env): return Object.keys(env).length > 0 ? env : undefined; } +/** + * Non-secret launch settings that may survive a supervisor restart in a + * resident worker descriptor. Model credentials deliberately do not belong + * here: the first worker launch inherits the caller environment, but a JSON + * descriptor must never become an at-rest copy of a caller's credentials. + */ +export const DAEMON_PERSISTED_LAUNCH_ENV_KEYS = [ + // Process/runtime locations needed to relaunch the same installed CLI. + "HOME", + "PATH", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + // Source/development installs may relaunch through tsx after recovery. + "TSX_TSCONFIG_PATH", + // ENV_AGENT_DIR is the current application's configurable agent directory. + // PI_CODING_AGENT_DIR remains for compatibility with the upstream CLI. + ENV_AGENT_DIR, + "PI_CODING_AGENT_DIR", + // Deliberately non-secret Prime Agent behavior, telemetry, and package settings. + "PI_OFFLINE", + "PI_PACKAGE_DIR", + "PI_SKIP_VERSION_CHECK", + "DO_NOT_TRACK", + "PRIME_AGENT_TELEMETRY", + "PRIME_AGENT_TELEMETRY_ENDPOINT", + "PRIME_AGENT_TRACES_BASE_URL", + "PRIME_AGENT_DOWNLOAD_BASE_URL", +] as const; + +/** Select the explicitly non-secret launch settings safe to persist on disk. */ +export function filterPersistedDaemonLaunchEnv( + source: Readonly> | undefined, +): Record | undefined { + if (!source) return undefined; + const env: Record = {}; + for (const key of DAEMON_PERSISTED_LAUNCH_ENV_KEYS) { + const value = source[key]; + if (value !== undefined) env[key] = value; + } + return Object.keys(env).length > 0 ? env : undefined; +} + +/** + * Collect the caller environment for the initial worker spawn. The + * supervisor filters it before it is written to a resident-worker descriptor. + */ export function collectDaemonLaunchEnv(source: NodeJS.ProcessEnv = process.env): Record { const env: Record = {}; for (const [key, value] of Object.entries(source)) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3069dd4af..18a13a264 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -54,6 +54,7 @@ import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-p import { deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js"; import { collectDaemonClientEnv, + collectDaemonLaunchEnv, createDaemonEventMeta, DAEMON_COMMAND_COMPATIBILITY, DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION, @@ -71,6 +72,7 @@ import { type DaemonResponse, type DaemonUpdateRestartManifest, failure, + filterPersistedDaemonLaunchEnv, isDaemonCommandEnvelope, isDaemonMutatingCommand, salvageDaemonCommandId, @@ -429,6 +431,12 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is (descriptor.pid ?? 0) > 0 && (descriptor.processStartId === undefined || typeof descriptor.processStartId === "string") && (descriptor.ownerClientId === undefined || typeof descriptor.ownerClientId === "string") && + (descriptor.launchEnv === undefined || + (typeof descriptor.launchEnv === "object" && + descriptor.launchEnv !== null && + Object.entries(descriptor.launchEnv).every( + ([key, value]) => typeof key === "string" && typeof value === "string", + ))) && typeof descriptor.socketPath === "string" && typeof descriptor.authenticationToken === "string" && typeof descriptor.rootActiveSessionId === "string" && @@ -928,10 +936,12 @@ export class DaemonSupervisor { if (!isDaemonWorkerDescriptor(descriptor, this.socketPath)) { continue; } + const storedLaunchEnv = descriptor.launchEnv; + descriptor.launchEnv = filterPersistedDaemonLaunchEnv(storedLaunchEnv); descriptor.lifecycle = "recovering"; descriptor.recoveryJournalPath ??= join(this.descriptorDir, `${descriptor.workerId}.recovery.jsonl`); descriptor.orphanProcessJournalPath ??= join(this.descriptorDir, `${descriptor.workerId}.orphans.jsonl`); - this.workers.set(descriptor.workerId, { + const worker: ResidentWorker = { descriptor, descriptorPath: path, summaries: new Map(), @@ -941,7 +951,12 @@ export class DaemonSupervisor { snapshotLoads: new Map(), intentionalStop: descriptor.stopRequestedAt !== undefined, stopRevision: 0, - }); + launchEnv: descriptor.launchEnv, + }; + this.workers.set(descriptor.workerId, worker); + if (JSON.stringify(storedLaunchEnv) !== JSON.stringify(descriptor.launchEnv)) { + this.persistWorker(worker); + } } catch (error) { this.log(`Ignoring invalid worker descriptor ${path}: ${String(error)}`); } @@ -1790,9 +1805,16 @@ export class DaemonSupervisor { const source = command.fromActiveSessionId ? await this.findWorkerForClient(client, command.fromActiveSessionId) : undefined; + // Hold this persisted topology through pre-wake and post-wake checks. + const familyCatalog = source && command.agentOrigin === true ? await this.familyCatalogEntries() : undefined; + // Session IDs are the stable identities for this authorization snapshot. Do not + // rebuild either endpoint from worker summaries after the snapshot is captured. + const sourceSessionId = source?.summary.sessionId; + let targetSessionId: string; let target: WorkerMatch; try { target = await this.findWorkerForClient(client, command.targetActiveSessionId); + targetSessionId = target.summary.sessionId; } catch (error) { if (!(error instanceof Error) || !error.message.startsWith("Unknown active session:")) throw error; const cwd = source?.summary.cwd ?? this.defaultSessionConfig.cwd ?? process.cwd(); @@ -1811,12 +1833,14 @@ export class DaemonSupervisor { } throw error; } + const targetInfo = await readSessionInfo(sessionPath); + if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); + targetSessionId = targetInfo.id; if (source && command.agentOrigin === true) { - const targetInfo = await readSessionInfo(sessionPath); - if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); assertAgentFamilyReach( - this.familyCatalogEntry(source.summary), - this.familyCatalogEntry(summaryForInactiveSession(targetInfo)), + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, ); } const worker = await this.createOrReuseWorker(this.protocolClientId(client), { @@ -1832,7 +1856,16 @@ export class DaemonSupervisor { } const targetActiveSessionId = target.summary.activeSessionId ?? target.summary.id; if (source && command.agentOrigin === true) { - assertAgentFamilyReach(this.familyCatalogEntry(source.summary), this.familyCatalogEntry(target.summary)); + // Waking must not substitute a different live session for the target that + // was authorized by the captured topology. + if (target.summary.sessionId !== targetSessionId) { + throw new Error("Agent reach is limited to parent, siblings, and children"); + } + assertAgentFamilyReach( + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, + ); } if (source) { if ((source.summary.activeSessionId ?? source.summary.id) === targetActiveSessionId) { @@ -2123,7 +2156,7 @@ export class DaemonSupervisor { throw new Error("Session is not owned by this client"); } const previousDescriptor = worker.descriptor; - worker.descriptor = { ...previousDescriptor, ownerClientId: undefined }; + worker.descriptor = { ...previousDescriptor, ownerClientId: undefined, launchEnv: undefined }; try { this.persistWorker(worker); } catch (error) { @@ -2149,8 +2182,29 @@ export class DaemonSupervisor { throw new Error(`Session worker ${existing.descriptor.workerId} recovery was cancelled`); } const recoveryStopRevision = existing?.stopRevision; - const launchEnv = - ownerClientId || existing?.descriptor.ownerClientId ? (command.launchEnv ?? existing?.launchEnv) : undefined; + const ownerClientIdForDescriptor = existing?.descriptor.ownerClientId ?? ownerClientId; + // Only a first resident launch consumes the caller's full transient + // environment. A resident recovery uses the descriptor's allowlisted copy + // even while the old worker object still exists in memory. Client-owned + // workers are different: their reconnecting owner supplies fresh transient + // launch settings, which are never written to a descriptor. + const launchEnv = existing + ? ownerClientIdForDescriptor === undefined + ? existing.descriptor.launchEnv + : existing.launchEnv + : command.launchEnv; + // Only non-secret, explicitly allowed settings are durable. The initial + // spawn may still receive caller credentials through launchEnv, but those + // credentials must never be serialized into a worker descriptor. + const persistedLaunchEnv = filterPersistedDaemonLaunchEnv(launchEnv); + // A replacement supervisor can itself have been restarted by the old worker + // and therefore inherit that worker's original credentials. Automatic + // resident recovery must not copy those ambient secrets into the replacement + // worker. Client-owned recovery instead uses its live owner's transient env. + const inheritedEnv = + existing && ownerClientIdForDescriptor === undefined + ? filterPersistedDaemonLaunchEnv(collectDaemonLaunchEnv(process.env)) + : process.env; const createCommand: DaemonCreateCommand = { ...withoutSupervisorCreateFields(command), config: mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config), @@ -2171,7 +2225,7 @@ export class DaemonSupervisor { cwd: createCommand.config?.cwd ?? process.cwd(), detached: true, env: createCliSubprocessEnv({ - ...process.env, + ...inheritedEnv, ...launchEnv, [DAEMON_WORKER_ROLE_ENV]: "1", [DAEMON_WORKER_TOKEN_ENV]: token, @@ -2227,7 +2281,10 @@ export class DaemonSupervisor { supervisorSocketPath: this.socketPath, authenticationToken: token, rootActiveSessionId, - ownerClientId: existing?.descriptor.ownerClientId ?? ownerClientId, + ownerClientId: ownerClientIdForDescriptor, + ...(ownerClientIdForDescriptor === undefined && persistedLaunchEnv + ? { launchEnv: persistedLaunchEnv } + : {}), createdAt: existing?.descriptor.createdAt ?? now, updatedAt: now, lifecycle: "starting", @@ -2248,7 +2305,10 @@ export class DaemonSupervisor { }; await this.assertRecoveryAllowed(); worker.descriptor = descriptor; - worker.launchEnv = launchEnv; + // Resident workers retain only the durable allowlist even in memory, so an + // automatic same-supervisor recovery cannot resurrect initial credentials. + // Client-owned workers may retain a fresh owner's transient environment. + worker.launchEnv = ownerClientIdForDescriptor === undefined ? persistedLaunchEnv : launchEnv; descriptorAssigned = true; this.persistWorker(worker); worker.intentionalStop = false; @@ -3008,19 +3068,17 @@ export class DaemonSupervisor { } } - private async familyCatalogEntries(): Promise { + private async familyCatalogEntries(): Promise { const active = [...this.workers.values()].flatMap((worker) => [...worker.summaries.values()]); const activePaths = new Set( active.flatMap((summary) => (summary.sessionFile ? [canonicalSessionPath(summary.sessionFile)] : [])), ); - const savedRoots = (await this.catalog.list()).filter( - (info) => - (info.rlmDepth ?? (info.parentSessionPath ? -1 : 0)) === 0 && - !activePaths.has(canonicalSessionPath(info.path)), - ); - return [...active, ...savedRoots.map((info) => summaryForInactiveSession(info))].map((summary) => - this.familyCatalogEntry(summary), - ); + // All persisted descendants participate. Retain duplicate stable identities so + // authoritative endpoint resolution can reject an ambiguous authorization snapshot. + const persisted = (await this.catalog.list()) + .filter((info) => !activePaths.has(canonicalSessionPath(info.path))) + .map((info) => this.familyCatalogEntry(summaryForInactiveSession(info))); + return Object.freeze([...persisted, ...active.map((summary) => this.familyCatalogEntry(summary))]); } private async withSessionNameReservation( @@ -3192,6 +3250,16 @@ export class DaemonSupervisor { return worker.client; } + /** Resolve both authorization endpoints exclusively from one captured topology snapshot. */ + private authoritativeFamilyCatalogEntry( + catalog: readonly AgentFamilyCatalogEntry[], + sessionId: string, + ): AgentFamilyCatalogEntry { + const matches = catalog.filter((entry) => entry.id === sessionId); + if (matches.length !== 1) throw new Error("Agent reach is limited to parent, siblings, and children"); + return matches[0]!; + } + private familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry { const depth = summary.rlmDepth ?? (summary.parentSessionPath ? 1 : 0); return { diff --git a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts index 51dea75bd..0683fbbae 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -96,6 +96,8 @@ export interface DaemonWorkerDescriptor { rootActiveSessionId: string; /** Stable protocol client that owns this worker. Omitted for resident sessions. */ ownerClientId?: string; + /** Environment required to relaunch a resident worker after supervisor restart. */ + launchEnv?: Record; rootSessionId?: string; sessionFile?: string; createdAt: string; diff --git a/packages/coding-agent/test/acp-kernel-features.test.ts b/packages/coding-agent/test/acp-kernel-features.test.ts index 6663fb94c..204904df1 100644 --- a/packages/coding-agent/test/acp-kernel-features.test.ts +++ b/packages/coding-agent/test/acp-kernel-features.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import type { KernelManager } from "../src/core/kernel/index.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; @@ -166,8 +167,8 @@ print(json.dumps({ provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [AGENT_MESSAGE_SKILL], env: { RLM_DEPTH: "0", RLM_MAX_DEPTH: "1" }, - hostHandlers: { - "rlm.list_subagents": async () => ({ + hostHandlers: createTestHostHandlers({ + "rlm.list_subagents": async (_payload, _context) => ({ subagents: [ { rlm_child_id: "child-1", @@ -179,7 +180,7 @@ print(json.dumps({ }, ], }), - "rlm.delete_subagent": async (payload) => ({ + "rlm.delete_subagent": async (payload, _context) => ({ subagent: { rlm_child_id: String(payload.target), active_session_id: null, @@ -189,7 +190,7 @@ print(json.dumps({ status: "completed", }, }), - }, + }), }); const manager = await provisioner.ensure(); @@ -219,13 +220,13 @@ print(json.dumps({ async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [AGENT_MESSAGE_SKILL], - hostHandlers: { + hostHandlers: createTestHostHandlers({ // The family roster: parent, siblings, and children of this agent. - "agent_message.list_agents": async () => ({ + "agent_message.list_agents": async (_payload, _context) => ({ current: { name: "root", id: "session-alpha", depth: 0 }, entries: [{ relationship: "child", name: "reviewer", id: "session-beta", depth: 1, status: "idle" }], }), - "agent_message.send": async (payload) => ({ + "agent_message.send": async (payload, _context) => ({ id: "agentmsg-acp", source: "agent_message", target: { activeSessionId: "beta", sessionId: "session-beta", sessionName: "reviewer" }, @@ -234,7 +235,7 @@ print(json.dumps({ queuedAt: "2026-08-04T00:00:00.000Z", deliveryMode: payload.mode ?? "auto", }), - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/agent-session-bus.test.ts b/packages/coding-agent/test/agent-session-bus.test.ts index 381dcbf53..beb0fc4c5 100644 --- a/packages/coding-agent/test/agent-session-bus.test.ts +++ b/packages/coding-agent/test/agent-session-bus.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { invokeHostRequest } from "./host-request-context.js"; import { + AGENT_FAMILY_REACH_ERROR, AGENT_MESSAGE_SOURCE, AgentSessionMessageRateLimiter, assertAgentFamilyReach, @@ -181,7 +183,7 @@ describe("agent session bus", () => { sendAgentMessage, }); - await handlers["agent_message.send"]!({ + await invokeHostRequest(handlers["agent_message.send"]!, { message: "hello", receiver_role: "sibling", receiver_name: "reviewer", @@ -193,7 +195,9 @@ describe("agent session bus", () => { }); sendAgentMessage.mockClear(); - await expect(handlers["agent_message.send"]!({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect( + invokeHostRequest(handlers["agent_message.send"]!, { target: "all", message: "status" }), + ).resolves.toMatchObject({ receipts: [ { id: "root", deliveryStatus: "delivered" }, { id: "sibling", deliveryStatus: "delivered" }, @@ -204,7 +208,7 @@ describe("agent session bus", () => { sendAgentMessage.mockClear(); await expect( - handlers["agent_message.send"]!({ + invokeHostRequest(handlers["agent_message.send"]!, { target: "all", message: "private", receiver_role: "sibling", @@ -224,9 +228,9 @@ describe("agent session bus", () => { sendAgentMessage, }); - await expect(handlers["agent_message.send"]!({ target: "reviewer", message: "status" })).rejects.toThrow( - "use receiver_role and receiver_name", - ); + await expect( + invokeHostRequest(handlers["agent_message.send"]!, { target: "reviewer", message: "status" }), + ).rejects.toThrow("use receiver_role and receiver_name"); expect(sendAgentMessage).not.toHaveBeenCalled(); }); @@ -253,7 +257,9 @@ describe("agent session bus", () => { sendAgentMessage, }); - await expect(handlers["agent_message.send"]!({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect( + invokeHostRequest(handlers["agent_message.send"]!, { target: "all", message: "status" }), + ).resolves.toMatchObject({ receipts: [ { id: "root", deliveryStatus: "delivered" }, { target: "sibling", error: "rate limited" }, @@ -303,10 +309,11 @@ describe("agent session bus", () => { { id: "orphan-b", depth: 3, status: "inactive" }, ), ).toThrow("Agent reach is limited to parent, siblings, and children"); - expect(assertAgentFamilyReach(root, child)).toBe("child"); - expect(assertAgentFamilyReach(child, root)).toBe("parent"); - expect(assertAgentFamilyReach(child, sibling)).toBe("sibling"); - expect(assertAgentFamilyReach(sibling, idOnlySibling)).toBe("sibling"); + const catalog = [root, child, sibling, idOnlySibling, grandchild]; + expect(assertAgentFamilyReach(root, child, catalog)).toBe("child"); + expect(assertAgentFamilyReach(child, root, catalog)).toBe("parent"); + expect(assertAgentFamilyReach(child, sibling, catalog)).toBe("sibling"); + expect(assertAgentFamilyReach(sibling, idOnlySibling, catalog)).toBe("sibling"); expect(() => assertAgentFamilyReach(root, grandchild)).toThrow( "Agent reach is limited to parent, siblings, and children", ); @@ -395,6 +402,67 @@ describe("agent session bus", () => { ]); }); + it("reserves passive sibling names from direct canonical parent claims without broadening family reach", () => { + const catalog = [ + { id: "passive-id", name: "worker", depth: 1, status: "inactive" as const, parentSessionId: "parent" }, + { + id: "passive-path", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/tmp/prime-agent-parent/../parent.jsonl", + }, + ]; + + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { + name: "path-worker", + depth: 1, + parentSessionPath: "/tmp/parent.jsonl", + }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + // Direct claims reserve names only. They cannot synthesize a relationship + // while the parent record is unavailable from the catalog. + expect(() => assertAgentFamilyReach(catalog[0]!, catalog[1]!, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + }); + it("resolves id-only and path-only catalog claims but rejects contradictory claims", () => { + const parent = { id: "parent", depth: 0, status: "running" as const, sessionPath: "/parent" }; + const idOnly = { + id: "id-only", + name: "id-worker", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + }; + const pathOnly = { + id: "path-only", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/parent", + }; + const contradictory = { + id: "contradictory", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + parentSessionPath: "/other", + }; + const catalog = [parent, idOnly, pathOnly, contradictory]; + expect(assertAgentFamilyReach(parent, idOnly, catalog)).toBe("child"); + expect(assertAgentFamilyReach(parent, pathOnly, catalog)).toBe("child"); + expect(() => assertAgentFamilyReach(parent, contradictory, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "id-worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "path-worker", depth: 1, parentSessionPath: "/parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + }); + it("builds a sorted nuclear-family roster with inactive members", () => { const catalog = [ { id: "root", name: "orchestrator", depth: 0, status: "running" as const, sessionPath: "/root" }, diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index f066f02b1..a0c33681b 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -22,7 +22,12 @@ import { import { AgentSession } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; import type { LoadExtensionsResult } from "../src/core/extensions/index.js"; -import { type HostRequestHandlers, KernelManager } from "../src/core/kernel/index.js"; +import { + createHostRequestHandler, + type HostRequestContext, + type HostRequestHandlers, + KernelManager, +} from "../src/core/kernel/index.js"; import { convertToLlm } from "../src/core/messages.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { @@ -37,6 +42,7 @@ import type { Skill } from "../src/core/skills.js"; import { createSyntheticSourceInfo } from "../src/core/source-info.js"; import { type ActiveSessionState, resolveActiveSessionState } from "../src/modes/daemon/active-session-state.js"; import { AgentDaemon } from "../src/modes/daemon/daemon-mode.js"; +import { invokeHostRequest } from "./host-request-context.js"; import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.js"; const model = getModel("anthropic", "claude-sonnet-4-5")!; @@ -328,7 +334,7 @@ describe("AgentSession rlm recursion", () => { outcome: "skipped_running", })); - await expect(deleteHandler({ target: subagent.rlm_child_id })).resolves.toEqual({ + await expect(invokeHostRequest(deleteHandler, { target: subagent.rlm_child_id })).resolves.toEqual({ subagent, outcome: "skipped_running", }); @@ -743,7 +749,7 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); expect(child.repliedToParentSinceTask).toBe(false); - await expect(send({ message: "done", receiver_role: "parent" })).resolves.toMatchObject({ + await expect(invokeHostRequest(send, { message: "done", receiver_role: "parent" })).resolves.toMatchObject({ message: "done", }); expect(sendAgentMessage).toHaveBeenCalledWith( @@ -802,7 +808,7 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - const pendingSend = send({ + const pendingSend = invokeHostRequest(send, { message: "hello", receiver_role: "child", receiver_name: spawned.rlm_child_id, @@ -865,7 +871,7 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); await expect( - send({ message: "follow-up", receiver_role: "child", receiver_name: spawned.rlm_child_id }), + invokeHostRequest(send, { message: "follow-up", receiver_role: "child", receiver_name: spawned.rlm_child_id }), ).resolves.toMatchObject({ message: "follow-up" }); expect(sendAgentMessage).toHaveBeenCalledWith( expect.objectContaining({ target: child.sessionId, message: "follow-up" }), @@ -901,7 +907,11 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - const pendingSend = send({ message: "hello", receiver_role: "child", receiver_name: spawned.name }); + const pendingSend = invokeHostRequest(send, { + message: "hello", + receiver_role: "child", + receiver_name: spawned.name, + }); rejectStartup?.(new Error("child startup failed")); await expect(pendingSend).rejects.toThrow("child startup failed"); @@ -957,7 +967,7 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); await expect( - send({ message: "hello", receiver_role: "child", receiver_name: "shared-child" }), + invokeHostRequest(send, { message: "hello", receiver_role: "child", receiver_name: "shared-child" }), ).resolves.toMatchObject({ message: "hello" }); expect(sendAgentMessage).toHaveBeenCalledWith( expect.objectContaining({ target: "healthy-child-session", message: "hello" }), @@ -999,9 +1009,9 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await expect(send({ message: "hello", receiver_role: "child", receiver_name: "deleted-child" })).rejects.toThrow( - 'No child matches "deleted-child"', - ); + await expect( + invokeHostRequest(send, { message: "hello", receiver_role: "child", receiver_name: "deleted-child" }), + ).rejects.toThrow('No child matches "deleted-child"'); releaseRuntimeCreation(); await waitFor(() => (root as unknown as InspectableRlmSession)._activeRlmChildRuns.size === 0); }); @@ -1038,7 +1048,7 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await expect(send({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect(invokeHostRequest(send, { target: "all", message: "status" })).resolves.toMatchObject({ receipts: [{ message: "status" }], }); expect(roster).toHaveBeenCalledTimes(1); @@ -1263,7 +1273,7 @@ describe("AgentSession rlm recursion", () => { vi.spyOn(child, "promptAndWait").mockImplementation(async () => { const send = (child as unknown as InspectableRlmSession)._createKernelHostHandlers()["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await send({ message: "done", receiver_role: "parent" }); + await invokeHostRequest(send, { message: "done", receiver_role: "parent" }); const followUp = createAgentSessionMessage({ id: "agentmsg-parent-follow-up-after-reply", source: "agent_message", @@ -1509,13 +1519,15 @@ describe("AgentSession rlm recursion", () => { if (!listHandler || !deleteHandler) { throw new Error("Missing RLM subagent registry host handlers"); } - await expect(listHandler({})).resolves.toEqual(expectedRegistry); - await expect(deleteHandler({ target: expectedSessionName })).resolves.toEqual({ + await expect(invokeHostRequest(listHandler, {})).resolves.toEqual(expectedRegistry); + await expect(invokeHostRequest(deleteHandler, { target: expectedSessionName })).resolves.toEqual({ subagent: expectedRegistry.subagents[0], }); expect(root.getRlmChildSession(daemonChildId)).toBeUndefined(); expect(await root.listRlmSubagents()).toEqual({ subagents: [] }); - await expect(deleteHandler({ target: expectedSessionName })).rejects.toThrow("No direct RLM subagent matches"); + await expect(invokeHostRequest(deleteHandler, { target: expectedSessionName })).rejects.toThrow( + "No direct RLM subagent matches", + ); root.dispose(); @@ -3122,6 +3134,136 @@ print(_result.name) } }); + it("retains an in-flight host reply-send failure while dispose waits", async () => { + let replySendStarted = false; + let releaseReplySend: () => void = () => {}; + const replySendGate = new Promise((resolve) => { + releaseReplySend = resolve; + }); + const manager = new KernelManager({ + python: process.execPath, + hostHandlers: { + "rlm.run": createRlmRunHostHandler(async () => ({ + answer: "unused", + usage: { prompt_tokens: 1, completion_tokens: 1 }, + turns: 1, + session_dir: null, + model: "test/model", + })), + }, + }); + + try { + const kernel = manager as unknown as KernelCommTestApi; + kernel.sendCommMessage = async () => { + replySendStarted = true; + await replySendGate; + throw new Error("reply transport failed"); + }; + kernel.handleCommMessage(rlmCommOpen("comm-reply-dispose", "child")); + + await waitFor(() => replySendStarted); + const disposePromise = manager.dispose(); + let disposeSettled = false; + void disposePromise.then(() => { + disposeSettled = true; + }); + await sleep(25); + expect(disposeSettled).toBe(false); + + releaseReplySend(); + await expectSettlesWithin(disposePromise, 1000); + expect((manager as unknown as { kernelStderr: string }).kernelStderr).toContain( + "[kernel] failed to send host request reply for comm comm-reply-dispose: reply transport failed", + ); + } finally { + releaseReplySend(); + await manager.dispose(); + } + }); + + it("times out noncooperative host handlers during dispose and retains late failures", async () => { + let started = false; + let releaseHandler: () => void = () => {}; + const handlerGate = new Promise((resolve) => { + releaseHandler = resolve; + }); + let resolveLateFailureDiagnostic: () => void = () => {}; + const lateFailureDiagnostic = new Promise((resolve) => { + resolveLateFailureDiagnostic = resolve; + }); + const manager = new KernelManager({ + python: process.execPath, + hostHandlers: { + "rlm.run": createHostRequestHandler( + async (_payload: Record, _context: HostRequestContext) => { + started = true; + await handlerGate; + throw new Error("noncooperative handler failed after disposal"); + }, + ), + }, + }); + const kernel = manager as unknown as KernelCommTestApi; + const sendCommMessage = vi.fn(async () => {}); + kernel.sendCommMessage = sendCommMessage; + const diagnosticTarget = manager as unknown as { + appendKernelDiagnostic(message: string): void; + commTargets: Map; + kernelStderr: string; + state: string; + }; + const appendKernelDiagnostic = diagnosticTarget.appendKernelDiagnostic.bind(manager); + const diagnosticSpy = vi.spyOn(diagnosticTarget, "appendKernelDiagnostic").mockImplementation((message) => { + appendKernelDiagnostic(message); + if ( + message === "host request failed for comm comm-noncooperative: noncooperative handler failed after disposal" + ) { + resolveLateFailureDiagnostic(); + } + }); + + vi.useFakeTimers(); + try { + kernel.handleCommMessage(rlmCommOpen("comm-noncooperative", "slow child")); + expect(started).toBe(true); + + const disposePromise = manager.dispose(); + let disposeSettled = false; + void disposePromise.then(() => { + disposeSettled = true; + }); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(4999); + expect(disposeSettled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await disposePromise; + expect(disposeSettled).toBe(true); + expect(diagnosticTarget.state).toBe("shutdown"); + expect(diagnosticTarget.commTargets).toEqual(new Map()); + expect(diagnosticTarget.kernelStderr).toBe( + "[kernel] timed out waiting 5000ms for 1 host request task(s) after revocation\n", + ); + + releaseHandler(); + await lateFailureDiagnostic; + await Promise.resolve(); + expect(diagnosticTarget.kernelStderr).toContain( + "[kernel] host request failed for comm comm-noncooperative: noncooperative handler failed after disposal", + ); + expect(diagnosticTarget.kernelStderr).toContain( + "[kernel] failed to send host request error reply for comm comm-noncooperative: host request authority was revoked", + ); + expect(sendCommMessage).not.toHaveBeenCalled(); + } finally { + releaseHandler(); + vi.useRealTimers(); + await manager.dispose(); + diagnosticSpy.mockRestore(); + } + }); + it("waits for in-flight rlm comm work during dispose and buffers failures", async () => { let started = false; let handlerSettled = false; diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index fe278efdb..fadba9a8c 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import { AGENT_FAMILY_REACH_ERROR, type AgentSessionMessageController, + assertAgentFamilyReach, DEFAULT_AGENT_MESSAGE_MAX_CHARS, sessionNameReservationKey, } from "../src/core/agent-messages.js"; @@ -46,6 +47,25 @@ import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js" import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV } from "../src/modes/daemon/daemon-worker-protocol.js"; describe("daemon mode helpers", () => { + const installDeterministicAgentFamilyCatalog = (internals: object, states: readonly ActiveSessionState[]) => { + const catalogTarget = internals as { + agentFamilyCatalogEntries?: () => Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + catalogTarget.agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze( + states.map((state) => ({ + id: state.runtime.session.sessionId, + depth: state.runtime.session.rlmDepth ?? 0, + status: "running" as const, + ...(state.runtime.metadata.parentSessionId + ? { parentSessionId: state.runtime.metadata.parentSessionId } + : {}), + })), + ), + ); + }; it("preserves envelope client identity while registering prompt admission", () => { const daemon = new AgentDaemon("/tmp/unused-daemon.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -241,6 +261,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const send = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -248,8 +269,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); resolvePrompt(); @@ -1833,6 +1855,7 @@ describe("daemon mode helpers", () => { internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetA.activeSessionId, targetA); internals.sessions.set(targetB.activeSessionId, targetB); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetA, targetB]); for (let i = 0; i < 3; i++) { await expect( @@ -2124,6 +2147,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2201,18 +2225,22 @@ describe("daemon mode helpers", () => { return fromState; }); + installDeterministicAgentFamilyCatalog(internals, [...senders, targetState]); + const sends: Promise[] = []; const errors: unknown[] = []; for (const [i, fromState] of senders.entries()) { - void internals - .sendAgentSessionMessage({ - targetSelector: targetState.activeSessionId, - message: `message ${i}`, - fromState, - origin: "agent", - }) - .catch((error) => { - errors.push(error); - }); + sends.push( + internals + .sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }) + .catch((error) => { + errors.push(error); + }), + ); } for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length < 12; attempt++) { await Promise.resolve(); @@ -2220,6 +2248,7 @@ describe("daemon mode helpers", () => { // With reservations held past queue time, 12 concurrent senders would // count as 24 against the 20-slot cap and the tail would reject. + await Promise.all(sends); expect(errors).toEqual([]); expect(queueAgentMessagePrompt).toHaveBeenCalledTimes(12); }); @@ -2714,6 +2743,163 @@ describe("daemon mode helpers", () => { ); }); + it("authorizes depth-two passive siblings only from the persisted daemon topology", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-deep-passive-acl-")); + try { + const fixture = makePersistedRlmDaemonFixture(tempDir); + const secondGrandchildDir = join(fixture.childSessionDir, "sibling-grandchild"); + const secondGrandchild = SessionManager.create(tempDir, secondGrandchildDir); + secondGrandchild.newSession({ parentSession: fixture.childSessionFile, rlmDepth: 2 }); + secondGrandchild.flushNow(); + const secondGrandchildFile = secondGrandchild.getSessionFile(); + if (!secondGrandchildFile) throw new Error("Missing sibling grandchild session"); + const childRegistry = join(fixture.childArtifactDir, "rlm-subagents.jsonl"); + writeFileSync( + childRegistry, + `${readFileSync(childRegistry, "utf8")}${JSON.stringify({ + type: "rlm_subagent", + childId: "sibling-grandchild", + sessionName: "sibling-grandchild", + sessionDir: secondGrandchildDir, + sessionFile: secondGrandchildFile, + parentSessionId: fixture.childSessionId, + parentSessionFile: fixture.childSessionFile, + rlmDepth: 2, + status: "completed", + createdAt: 2, + updatedAt: "2026-01-01T00:00:02.000Z", + })}\n`, + ); + const internals = fixture.daemon as unknown as { + createRuntime(command: Extract): Promise; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + const catalog = await internals.agentFamilyCatalogEntries(); + const first = catalog.find((entry) => entry.id === fixture.grandchildSessionId); + const second = catalog.find((entry) => entry.id === secondGrandchild.getSessionId()); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(catalog).toContainEqual(expect.objectContaining({ id: fixture.childSessionId, depth: 1 })); + expect(assertAgentFamilyReach(first!, second!, catalog)).toBe("sibling"); + + // A depth-two pair claiming the root cannot manufacture the missing depth-one edge. + expect(() => + assertAgentFamilyReach( + { ...first!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + { ...second!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + catalog, + ), + ).toThrow(AGENT_FAMILY_REACH_ERROR); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("observes residents from the captured family catalog rather than live endpoint fields", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-observe-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const source = makeState("source"); + const target = makeState("target"); + for (const state of [source, target]) { + state.runtime = { + ...state.runtime, + metadata: { kind: "top-level", createdAt: 1 }, + diagnostics: [], + session: { + ...state.runtime.session, + messages: [], + hasRunningRlmChildren: vi.fn(() => false), + sessionManager: { + getHeader: vi.fn(() => ({})), + getCwd: vi.fn(() => "/tmp"), + getSessionArtifactDir: vi.fn(() => undefined), + }, + getSessionActionSnapshot: vi.fn(() => ({ queuedCount: 0, steering: [], followUps: [] })), + state: { streamingMessage: undefined, pendingToolCalls: new Map() }, + sessionId: `session-${state.activeSessionId}`, + sessionFile: `/tmp/${state.activeSessionId}.jsonl`, + rlmDepth: 0, + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + internals.sessions.set(source.activeSessionId, source); + internals.sessions.set(target.activeSessionId, target); + Object.assign(internals, { + agentFamilyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "left-parent", depth: 0, status: "inactive", sessionPath: "/tmp/left.jsonl" }, + { id: "right-parent", depth: 0, status: "inactive", sessionPath: "/tmp/right.jsonl" }, + { id: "session-source", depth: 1, status: "running", parentSessionId: "left-parent" }, + { id: "session-target", depth: 1, status: "running", parentSessionId: "right-parent" }, + ]), + ), + }); + + // The live root summaries would otherwise be sibling roots. Their conflicting + // topology cannot override the captured snapshot's unrelated parent edges. + const observed = await internals.createAgentObserveController(() => source).listAgents(); + expect(observed.agents.map((agent) => agent.activeSessionId)).toEqual(["source"]); + }); + + it("fails closed for every agent ACL surface when the captured catalog duplicates a stable session ID", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-duplicate-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const parent = makeAgentFamilyState("parent", "parent"); + const source = makeAgentFamilyState("source", "source", parent.state); + const target = makeAgentFamilyState("target", "target", parent.state); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentMessageController(getCurrentState: () => ActiveSessionState): AgentSessionMessageController; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + for (const fixture of [parent, source, target]) + internals.sessions.set(fixture.state.activeSessionId, fixture.state); + const sourceId = source.state.runtime.session.sessionId; + const parentId = parent.state.runtime.session.sessionId; + const targetId = target.state.runtime.session.sessionId; + const entries = [ + { id: parentId, depth: 0, status: "running" as const }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: parentId }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: "forged-parent" }, + { id: targetId, depth: 1, status: "running" as const, parentSessionId: parentId }, + ]; + + // The current observer must be resolved from the immutable catalog before + // self inclusion. Either duplicate ordering is ambiguous and must fail closed. + for (const catalog of [entries, [...entries.slice(0, 1), entries[2]!, entries[1]!, entries[3]!]]) { + internals.agentFamilyCatalogEntries = vi.fn(async () => Object.freeze(catalog)); + const observe = internals.createAgentObserveController(() => source.state); + await expect(observe.listAgents()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.getAgent(target.state.activeSessionId)).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.recentMessages({ target: target.state.activeSessionId })).rejects.toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + await expect( + internals + .createAgentMessageController(() => source.state) + .sendAgentMessage({ target: target.state.activeSessionId, message: "must not deliver" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + } + expect(target.acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + it("resolves a duplicate session name to the only family-reachable agent", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-family-name-resolution.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -2858,6 +3044,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2871,15 +3058,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length < 2; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(2); expect(followUp).not.toHaveBeenCalled(); @@ -3213,6 +3402,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3226,15 +3416,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); (targetState.runtime.session as { isStreaming: boolean }).isStreaming = true; promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); @@ -3667,6 +3859,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3680,8 +3873,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } (targetState.runtime.session as { unfinishedActionCount: number }).unfinishedActionCount = 20; resolveFirstPrompt(); @@ -4024,6 +4218,7 @@ describe("daemon mode helpers", () => { }): Promise; }; internals.sessions.set(state.activeSessionId, state); + installDeterministicAgentFamilyCatalog(internals, [state]); await expect( internals.sendAgentSessionMessage({ @@ -5745,13 +5940,15 @@ describe("daemon mode helpers", () => { sessionPath: fixture.parentSessionFile, }); - await internals - .createAgentMessageController(() => parentState) - .sendAgentMessage({ target: "renamed-worker", message: "report progress" }); - - // The nested header depth must win over the legacy depth-1 default so the - // woken child does not come up shallower than persisted. - expect(fixture.createRuntime.mock.calls[1]?.[0].sessionOptions?.rlmDepth).toBe(2); + // A root may not directly reach a depth-two child. The persisted header + // is authoritative even when legacy registry metadata omits depth, and denial + // must happen before hydration. + await expect( + internals + .createAgentMessageController(() => parentState) + .sendAgentMessage({ target: "renamed-worker", message: "report progress" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(fixture.createRuntime).toHaveBeenCalledOnce(); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -9694,7 +9891,7 @@ function makePersistedRlmDaemonFixture( const childId = "child-1"; const childSessionDir = join(parentArtifactDir, "sub-1234abcd"); const childManager = SessionManager.create(tempDir, childSessionDir); - childManager.newSession({ parentSession: parentSessionFile }); + childManager.newSession({ parentSession: parentSessionFile, rlmDepth: 1 }); childManager.appendSessionInfo("spawn-worker"); childManager.appendSessionInfo("renamed-worker"); childManager.appendMessage({ role: "user", content: "complete this task", timestamp: 1 }); @@ -9708,7 +9905,7 @@ function makePersistedRlmDaemonFixture( mkdirSync(childArtifactDir, { recursive: true }); const grandchildSessionDir = join(childSessionDir, "sub-deadbeef"); const grandchildManager = SessionManager.create(tempDir, grandchildSessionDir); - grandchildManager.newSession({ parentSession: childSessionFile }); + grandchildManager.newSession({ parentSession: childSessionFile, rlmDepth: 2 }); grandchildManager.appendSessionInfo("nested-worker"); grandchildManager.appendMessage({ role: "user", content: "complete the nested task", timestamp: 2 }); grandchildManager.flushNow(); @@ -9824,9 +10021,12 @@ function makePersistedRlmDaemonFixture( parentArtifactDir, parentSessionId: parentManager.getSessionId(), childId, + childSessionId: childManager.getSessionId(), childSessionFile, childSessionDir, + childArtifactDir, grandchildId, + grandchildSessionId: grandchildManager.getSessionId(), grandchildSessionFile, }; } diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 6b2542444..63bb2a27d 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../src/core/session-manager.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; @@ -31,8 +32,9 @@ interface SupervisorInternals { workers: Map; clients: Set<{ id: string; attachedActiveSessionIds: Set }>; idleEvictionFence?: Promise; - catalog: { resolve: ReturnType; stop: ReturnType }; + catalog: { resolve: ReturnType; stop: ReturnType; list?: ReturnType }; createOrReuseWorker: ReturnType; + familyCatalogEntries(): Promise; stopWorker: ReturnType; log: ReturnType; scheduleIdleEvictionSweep(): void; @@ -311,9 +313,19 @@ describe("daemon supervisor whole-tree eviction", () => { const source = makeWorker("source", [sourceSummary]); source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; source.summaries = new Map([["source-active", sourceSummary]]); + // The wake path reads this row before authorizing it, so model an actual + // saved session rather than a summary whose sessionFile is not readable. + const targetDirectory = mkdtempSync(join(tmpdir(), "prime-supervisor-saved-target-")); + tempDirs.push(targetDirectory); + const targetManager = SessionManager.create(targetDirectory, join(targetDirectory, "sessions")); + targetManager.newSession(); + targetManager.appendSessionInfo("saved target"); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing saved target session path"); const targetSummary = makeSummary("target-active", now, { - sessionId: "target-session", - sessionFile: "/tmp/target.jsonl", + sessionId: targetManager.getSessionId(), + sessionFile: targetPath, }); const target = makeWorker("target", [targetSummary]); target.descriptor.rootActiveSessionId = "target-active"; @@ -324,7 +336,7 @@ describe("daemon supervisor whole-tree eviction", () => { data: { deliveryStatus: "delivered" }, }); supervisor.workers.set("source", source); - supervisor.catalog.resolve = vi.fn(async () => "/tmp/target.jsonl"); + supervisor.catalog.resolve = vi.fn(async () => targetPath); supervisor.createOrReuseWorker = vi.fn(async () => target); const client = { id: "sender", attachedActiveSessionIds: new Set() }; @@ -339,7 +351,7 @@ describe("daemon supervisor whole-tree eviction", () => { expect(supervisor.catalog.resolve).toHaveBeenCalledWith("target-session", "/tmp/project", "/tmp/custom-sessions"); expect(supervisor.createOrReuseWorker).toHaveBeenCalledWith( "sender", - expect.objectContaining({ type: "create", sessionPath: "/tmp/target.jsonl", continueRecent: false }), + expect.objectContaining({ type: "create", sessionPath: targetPath, continueRecent: false }), ); expect(target.client?.requestWorker).toHaveBeenCalledWith( expect.objectContaining({ @@ -441,4 +453,154 @@ describe("daemon supervisor whole-tree eviction", () => { ).rejects.toThrow('Ambiguous session selector "target"'); expect(supervisor.createOrReuseWorker).not.toHaveBeenCalled(); }); + + it("captures every inactive descendant for cross-worker sibling authorization", async () => { + const supervisor = makeSupervisor(); + const timestamp = new Date("2026-08-01T12:00:00.000Z"); + const catalog = [ + { + id: "root", + path: "/tmp/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "middle", + path: "/tmp/middle.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "first", + path: "/tmp/first.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "second", + path: "/tmp/second.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + const entries = await supervisor.familyCatalogEntries(); + expect(entries.map((entry) => entry.id)).toEqual(["root", "middle", "first", "second"]); + const { assertAgentFamilyReach } = await import("../src/core/agent-messages.js"); + expect( + assertAgentFamilyReach( + entries.find((entry) => entry.id === "first")!, + entries.find((entry) => entry.id === "second")!, + entries, + ), + ).toBe("sibling"); + }); + + it("rejects duplicate snapshot identities before cross-worker delivery", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const targetSummary = makeSummary("target-active", now, { sessionId: "target" }); + const source = makeWorker("source", [sourceSummary]); + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "root", depth: 0, status: "inactive" as const }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "forged" }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "duplicate", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("rejects a postwake session substitution without delivery", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-postwake-substitution-")); + tempDirs.push(directory); + const parentManager = SessionManager.create(directory, join(directory, "sessions")); + parentManager.newSession({ rlmDepth: 0 }); + parentManager.flushNow(); + const parentPath = parentManager.getSessionFile(); + if (!parentPath) throw new Error("Missing parent session path"); + const targetManager = SessionManager.create(directory, join(directory, "sessions")); + targetManager.newSession({ parentSession: parentPath, rlmDepth: 1 }); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing target session path"); + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const source = makeWorker("source", [sourceSummary]); + const substituted = makeSummary("woken-active", now, { sessionId: "substitute", sessionFile: targetPath }); + const woken = makeWorker("woken", [substituted]); + const supervisor = makeSupervisor(); + supervisor.workers.set("source", source); + supervisor.catalog.resolve = vi.fn(async () => targetPath); + supervisor.createOrReuseWorker = vi.fn(async () => woken); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: parentManager.getSessionId(), depth: 0, status: "inactive" as const, sessionPath: parentPath }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: parentManager.getSessionId() }, + { + id: targetManager.getSessionId(), + depth: 1, + status: "inactive" as const, + parentSessionPath: parentPath, + sessionPath: targetPath, + }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "substitution", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: targetManager.getSessionId(), + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(supervisor.createOrReuseWorker).toHaveBeenCalledOnce(); + expect(woken.client?.requestWorker).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/host-request-context.ts b/packages/coding-agent/test/host-request-context.ts new file mode 100644 index 000000000..a4eaa605f --- /dev/null +++ b/packages/coding-agent/test/host-request-context.ts @@ -0,0 +1,56 @@ +import { + createHostRequestHandler, + type HostRequestContext, + type HostRequestHandler, +} from "../src/core/kernel/index.js"; + +let nextSyntheticHostRequestId = 0; + +/** Create a distinct, current dispatcher context for direct host-handler tests. */ +export function createSyntheticHostRequestContext(): HostRequestContext { + const requestNumber = ++nextSyntheticHostRequestId; + const controller = new AbortController(); + return { + requestId: `test-host-request-${requestNumber}`, + generation: requestNumber, + signal: controller.signal, + isCurrent: () => !controller.signal.aborted, + }; +} + +/** Invoke a production-shaped handler with a synthetic dispatcher context. */ +export function invokeHostRequest( + handler: HostRequestHandler, + payload: Record, +): Promise> { + return handler(payload, createSyntheticHostRequestContext()); +} + +/** Build branded test fixture handlers through the production capability factory. */ +export function createTestHostHandlers Promise>>>( + handlers: { + [K in keyof T]: T[K] extends ( + payload: infer P, + context: infer C, + ...rest: any[] + ) => Promise> + ? Record extends P + ? HostRequestContext extends C + ? T[K] + : never + : never + : never; + }, +): Record { + return Object.fromEntries( + Object.entries(handlers).map(([type, handler]) => [ + type, + createHostRequestHandler( + handler as ( + payload: Record, + context: HostRequestContext, + ) => Promise>, + ), + ]), + ) as Record; +} diff --git a/packages/coding-agent/test/kernel-abort.test.ts b/packages/coding-agent/test/kernel-abort.test.ts index 91d544cd1..1fc02e117 100644 --- a/packages/coding-agent/test/kernel-abort.test.ts +++ b/packages/coding-agent/test/kernel-abort.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { AGENT_MESSAGE_DISPLAY_MIME, KernelManager, type KernelSentAgentMessage } from "../src/core/kernel/index.js"; +import { + AGENT_MESSAGE_DISPLAY_MIME, + createHostRequestHandler, + type HostRequestHandler, + KernelManager, + type KernelSentAgentMessage, +} from "../src/core/kernel/index.js"; async function waitForCalls(mock: { mock: { calls: unknown[][] } }, count: number): Promise { for (let i = 0; i < 20; i++) { @@ -313,4 +319,61 @@ describe("KernelManager abort handling", () => { expect(controlSend).toHaveBeenCalled(); manager.disposeSync(); }); + it("rejects unary host implementations before they can run", async () => { + let called = false; + const unary = async (_payload: Record) => { + called = true; + return {}; + }; + // @ts-expect-error HostRequestHandler is nominal; raw unary callbacks cannot cross the boundary. + const _unbranded: HostRequestHandler = unary; + void _unbranded; + // @ts-expect-error Factory requires a payload and HostRequestContext implementation. + expect(() => createHostRequestHandler(unary)).toThrow("must accept payload and context"); + expect(called).toBe(false); + }); + + it("rejects missing context at the branded wrapper before implementation", async () => { + let called = false; + const handler = createHostRequestHandler( + async ( + _payload: Record, + _context: import("../src/core/kernel/index.js").HostRequestContext, + ) => { + called = true; + return {}; + }, + ); + await expect((handler as any)({ type: "agent_observe.list" })).rejects.toThrow("context is invalid"); + expect(called).toBe(false); + }); + it("rejects a copied-symbol branded forgery before its logic runs", async () => { + let called = false; + const genuine = createHostRequestHandler( + async ( + _payload: Record, + _context: import("../src/core/kernel/index.js").HostRequestContext, + ) => ({}), + ); + const forged = async (_payload: Record, _context: unknown) => { + called = true; + return {}; + }; + for (const symbol of Object.getOwnPropertySymbols(genuine)) { + Object.defineProperty(forged, symbol, { value: (genuine as any)[symbol] }); + } + const manager = new KernelManager({ hostHandlers: { "agent_observe.list": forged as any } }); + await expect( + (manager as any).handleHostRequest( + { type: "agent_observe.list" }, + { + requestId: "forged", + generation: 1, + signal: new AbortController().signal, + isCurrent: () => true, + }, + ), + ).rejects.toThrow("not a dispatcher-created capability"); + expect(called).toBe(false); + }); }); diff --git a/packages/coding-agent/test/kernel-agent-message-skill.test.ts b/packages/coding-agent/test/kernel-agent-message-skill.test.ts index 5e75b8883..e7d9c2f13 100644 --- a/packages/coding-agent/test/kernel-agent-message-skill.test.ts +++ b/packages/coding-agent/test/kernel-agent-message-skill.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import { KernelManager, type KernelSentAgentMessage } from "../src/core/kernel/index.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; @@ -44,15 +45,15 @@ describe("agent-message skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.list_agents": async (payload) => { + hostHandlers: createTestHostHandlers({ + "agent_message.list_agents": async (payload, _context) => { requests.push({ type: "agent_message.list_agents", payload }); return { current: { name: "alpha", id: "session-alpha", depth: 0 }, entries: [{ relationship: "sibling", name: "Beta", id: "session-beta", depth: 0, status: "idle" }], }; }, - "agent_message.send": async (payload) => { + "agent_message.send": async (payload, _context) => { requests.push({ type: "agent_message.send", payload }); return { id: "agentmsg-test", @@ -64,7 +65,7 @@ describe("agent-message skill over the kernel host bridge", () => { queuedAt: "2026-06-16T00:00:00.000Z", }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -117,8 +118,8 @@ print(json.dumps({"agents": agents, "receipt": receipt}, sort_keys=True)) it("emits successful broadcast receipts and leaves short errors in the result", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.send": async (payload) => ({ + hostHandlers: createTestHostHandlers({ + "agent_message.send": async (payload, _context) => ({ receipts: [ { id: "agentmsg-root", @@ -132,7 +133,7 @@ print(json.dumps({"agents": agents, "receipt": receipt}, sort_keys=True)) { target: "sibling", error: "rate limited" }, ], }), - }, + }), }); const manager = await provisioner.ensure(); @@ -162,11 +163,11 @@ print(json.dumps(receipt, sort_keys=True)) it("rejects broadcast combined with role selectors before reaching the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.send": async () => { + hostHandlers: createTestHostHandlers({ + "agent_message.send": async (_payload, _context) => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -183,11 +184,11 @@ except TypeError as error: it("rejects a positional name target before reaching the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.send": async () => { + hostHandlers: createTestHostHandlers({ + "agent_message.send": async (_payload, _context) => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -206,11 +207,11 @@ except TypeError as error: it("does not expose a queueable delivery mode", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.send": async () => { + hostHandlers: createTestHostHandlers({ + "agent_message.send": async (_payload, _context) => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -227,8 +228,8 @@ except TypeError as error: it("captures sent messages from detached tasks after the cell is idle", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { - "agent_message.send": async (payload) => ({ + hostHandlers: createTestHostHandlers({ + "agent_message.send": async (payload, _context) => ({ id: "agentmsg-background", source: "agent_message", target: { activeSessionId: payload.receiver_name, sessionId: "session-beta", sessionName: "Beta" }, @@ -237,7 +238,7 @@ except TypeError as error: deliveredAt: "2026-07-10T00:00:00.000Z", deliveryMode: payload.mode, }), - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts index 0608bd6c0..74aeb1810 100644 --- a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts +++ b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; @@ -35,8 +36,8 @@ describe("agent-observe skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentObserveSkill()], - hostHandlers: { - "agent_observe.list": async (payload) => { + hostHandlers: createTestHostHandlers({ + "agent_observe.list": async (payload, _context) => { requests.push({ type: "agent_observe.list", payload }); return { current: { activeSessionId: "alpha", sessionId: "session-alpha", isCurrent: true }, @@ -46,11 +47,11 @@ describe("agent-observe skill over the kernel host bridge", () => { ], }; }, - "agent_observe.get": async (payload) => { + "agent_observe.get": async (payload, _context) => { requests.push({ type: "agent_observe.get", payload }); return { agent: { activeSessionId: payload.target, sessionId: "session-beta", status: "model" } }; }, - "agent_observe.recent": async (payload) => { + "agent_observe.recent": async (payload, _context) => { requests.push({ type: "agent_observe.recent", payload }); return { agent: { activeSessionId: payload.target, sessionId: "session-beta" }, @@ -60,7 +61,7 @@ describe("agent-observe skill over the kernel host bridge", () => { truncated: false, }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -93,11 +94,11 @@ print(json.dumps({"agents": agents, "agent": agent, "recent": recent}, sort_keys it("validates argument types before sending to the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentObserveSkill()], - hostHandlers: { - "agent_observe.get": async () => { + hostHandlers: createTestHostHandlers({ + "agent_observe.get": async (_payload, _context) => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-attach-image-skill.test.ts b/packages/coding-agent/test/kernel-attach-image-skill.test.ts index b47be17c3..988668421 100644 --- a/packages/coding-agent/test/kernel-attach-image-skill.test.ts +++ b/packages/coding-agent/test/kernel-attach-image-skill.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner, imageBlocksFromAttachments } from "../src/core/tools/ipython.js"; @@ -40,9 +41,12 @@ describe("attach-image skill over the kernel host bridge", () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -63,9 +67,12 @@ describe("attach-image skill over the kernel host bridge", () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -88,9 +95,12 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -113,9 +123,12 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -140,9 +153,12 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -178,9 +194,12 @@ except ValueError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); @@ -215,9 +234,9 @@ except ValueError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "openai/gpt-oss-120b", input: ["text"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ id: "openai/gpt-oss-120b", input: ["text"] }), + }), }); const manager = await provisioner.ensure(); @@ -242,9 +261,12 @@ except RuntimeError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { - "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + hostHandlers: createTestHostHandlers({ + "model.info": async (_payload, _context) => ({ + id: "anthropic/claude-haiku-4.5", + input: ["text", "image"], + }), + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-goal-skill.test.ts b/packages/coding-agent/test/kernel-goal-skill.test.ts index 239ffa351..10fa8f80f 100644 --- a/packages/coding-agent/test/kernel-goal-skill.test.ts +++ b/packages/coding-agent/test/kernel-goal-skill.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; @@ -35,8 +36,8 @@ describe("goal skill over the kernel host bridge", { tags: ["kernel-heavy"] }, ( const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { - "goal.create": async (payload) => { + hostHandlers: createTestHostHandlers({ + "goal.create": async (payload, _context) => { requests.push({ type: "goal.create", payload }); return { goal: { objective: payload.objective, status: "active", tokens_used: 0 }, @@ -44,7 +45,7 @@ describe("goal skill over the kernel host bridge", { tags: ["kernel-heavy"] }, ( completion_budget_report: null, }; }, - "goal.complete": async (payload) => { + "goal.complete": async (payload, _context) => { requests.push({ type: "goal.complete", payload }); return { goal: { objective: "ship it", status: "complete", tokens_used: 7 }, @@ -53,7 +54,7 @@ describe("goal skill over the kernel host bridge", { tags: ["kernel-heavy"] }, ( "Goal achieved. Report final budget usage to the user: tokens used: 7 of 10.", }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -85,11 +86,11 @@ print(_completed["goal"]["status"], _completed["completion_budget_report"]) it("surfaces host errors and missing handlers as Python exceptions", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { - "goal.complete": async () => { + hostHandlers: createTestHostHandlers({ + "goal.complete": async (_payload, _context) => { throw new Error("cannot complete goal because this thread has no goal"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -128,9 +129,9 @@ except RuntimeError as error: it("rejects replies with an unexpected status instead of hanging", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { - "goal.get": async () => ({ status: "partial" }), - }, + hostHandlers: createTestHostHandlers({ + "goal.get": async (_payload, _context) => ({ status: "partial" }), + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts index 8a47a0f91..129c92612 100644 --- a/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts +++ b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getBundledSkillsDir } from "../src/config.js"; +import { createTestHostHandlers } from "./host-request-context.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; @@ -35,8 +36,8 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: { - "rlm_heartbeat.create": async (payload) => { + hostHandlers: createTestHostHandlers({ + "rlm_heartbeat.create": async (payload, _context) => { requests.push({ type: "rlm_heartbeat.create", payload }); return { heartbeat: { @@ -50,7 +51,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { }, }; }, - "rlm_heartbeat.list": async (payload) => { + "rlm_heartbeat.list": async (payload, _context) => { requests.push({ type: "rlm_heartbeat.list", payload }); return { heartbeats: [ @@ -63,7 +64,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { ], }; }, - "rlm_heartbeat.update": async (payload) => { + "rlm_heartbeat.update": async (payload, _context) => { requests.push({ type: "rlm_heartbeat.update", payload }); return { heartbeat: { @@ -74,7 +75,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { }, }; }, - "rlm_heartbeat.delete": async (payload) => { + "rlm_heartbeat.delete": async (payload, _context) => { requests.push({ type: "rlm_heartbeat.delete", payload }); return { heartbeat: { @@ -85,7 +86,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { }, }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -131,7 +132,7 @@ print(json.dumps({ it("surfaces missing host handlers as Python exceptions", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: {}, + hostHandlers: createTestHostHandlers({}), }); const manager = await provisioner.ensure(); @@ -151,12 +152,12 @@ except RuntimeError as error: let hostRequestCount = 0; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: { - "rlm_heartbeat.create": async () => { + hostHandlers: createTestHostHandlers({ + "rlm_heartbeat.create": async (_payload, _context) => { hostRequestCount++; return {}; }, - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/mcp-manager.test.ts b/packages/coding-agent/test/mcp-manager.test.ts index 366a207ee..6f7d9f40d 100644 --- a/packages/coding-agent/test/mcp-manager.test.ts +++ b/packages/coding-agent/test/mcp-manager.test.ts @@ -7,6 +7,7 @@ import { AuthStorage } from "../src/core/auth-storage.js"; import { McpManager } from "../src/core/mcp/mcp-manager.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import type { McpServerConfig } from "../src/core/settings-manager.js"; +import { invokeHostRequest } from "./host-request-context.js"; describe("McpManager", () => { let tempDir: string; @@ -79,8 +80,10 @@ describe("McpManager", () => { // refresh with no credentials fails (so the kernel reports a refresh error, // not a false success), and a missing server arg is rejected. - await expect(handlers["mcp.refresh"]({ server: "linear" })).rejects.toThrow("Could not refresh"); - await expect(handlers["mcp.refresh"]({})).rejects.toThrow("requires a server"); + await expect(invokeHostRequest(handlers["mcp.refresh"] as never, { server: "linear" })).rejects.toThrow( + "Could not refresh", + ); + await expect(invokeHostRequest(handlers["mcp.refresh"] as never, {})).rejects.toThrow("requires a server"); }); it("exposes mcp.begin_login only when beginLogin is provided", async () => { @@ -93,7 +96,7 @@ describe("McpManager", () => { }); const handlers = manager.hostHandlers(); expect(Object.keys(handlers).sort()).toEqual(["mcp.begin_login", "mcp.config", "mcp.refresh"]); - await handlers["mcp.begin_login"]({ server: "linear" }); + await invokeHostRequest(handlers["mcp.begin_login"] as never, { server: "linear" }); expect(called).toBe("linear"); }); @@ -105,11 +108,13 @@ describe("McpManager", () => { }), }); const handlers = manager.hostHandlers(); - expect(await handlers["mcp.config"]({ server: "linear" })).toEqual({ + expect(await invokeHostRequest(handlers["mcp.config"] as never, { server: "linear" })).toEqual({ url: "https://proxy.test/mcp", headers: { "X-Extra": "1" }, }); - expect(await handlers["mcp.config"]({ server: "notion" })).toEqual({ url: "https://mcp.notion.com/mcp" }); + expect(await invokeHostRequest(handlers["mcp.config"] as never, { server: "notion" })).toEqual({ + url: "https://mcp.notion.com/mcp", + }); }); it("does not treat an oauth override of a catalog name as authed via the official stored cred", () => { diff --git a/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts b/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts index 61de890c0..6eeb59ae5 100644 --- a/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts @@ -2,6 +2,7 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, it, vi } from "vitest"; import type { HostRequestHandlers } from "../../../src/core/kernel/index.js"; import { SessionManager } from "../../../src/core/session-manager.js"; +import { invokeHostRequest } from "../../host-request-context.js"; import { createHarness } from "../harness.js"; const provider = "faux-eng-4649"; @@ -27,7 +28,7 @@ describe("ENG-4649 subagent model selection", () => { )._createKernelHostHandlers(); const findModels = handlers["rlm.find_models"]; if (!findModels) throw new Error("Missing rlm.find_models host handler"); - await expect(findModels({ query: "model 319", limit: 5 })).resolves.toEqual({ + await expect(invokeHostRequest(findModels, { query: "model 319", limit: 5 })).resolves.toEqual({ models: [ { provider, @@ -37,7 +38,9 @@ describe("ENG-4649 subagent model selection", () => { }, ], }); - await expect(findModels({ query: "model", limit: 21 })).rejects.toThrow("integer from 1 to 20"); + await expect(invokeHostRequest(findModels, { query: "model", limit: 21 })).rejects.toThrow( + "integer from 1 to 20", + ); harness.setResponses([fauxAssistantMessage("resolved child answer")]); const result = await harness.session.runRlmChild("use the requested model", { From e599d08573508bc1ad16b97a85c48a9cab2074e5 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 05:35:37 -0700 Subject: [PATCH 3/8] fix(coding-agent): anchor saved sibling validation --- .../src/modes/daemon/daemon-supervisor.ts | 42 +++++--- .../coding-agent/test/agent-messages.test.ts | 101 ++++++++++++++++++ .../daemon-supervisor-lazy-subagents.test.ts | 39 +++++++ 3 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/agent-messages.test.ts diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 18a13a264..967bb0fec 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3160,19 +3160,37 @@ export class DaemonSupervisor { private assertSavedSiblingNameAvailable(siblings: SessionInfo[], target: SessionInfo, name: string): void { const setDepth = target.rlmDepth ?? siblings.find((sibling) => sibling.rlmDepth !== undefined)?.rlmDepth ?? 0; + const parentSessionPath = target.parentSessionPath ? canonicalSessionPath(target.parentSessionPath) : undefined; + // The bounded sibling catalog intentionally omits its parent. Add one local + // structural anchor so C05's exact-one-parent check can compare legacy rows + // whose depth is inferred from this modern sibling set. + const parent = + setDepth > 0 && parentSessionPath + ? [ + { + id: `saved-sibling-parent:${parentSessionPath}`, + depth: setDepth - 1, + status: "inactive" as const, + sessionPath: parentSessionPath, + }, + ] + : []; assertAgentSessionNameAvailable( - siblings.map((info) => { - const summary = summaryForInactiveSession(info); - return { - id: summary.sessionId, - ...(summary.sessionName ? { name: summary.sessionName } : {}), - depth: setDepth, - status: classifySessionRosterStatus(summary), - ...(summary.parentSessionPath - ? { parentSessionPath: canonicalSessionPath(summary.parentSessionPath) } - : {}), - }; - }), + [ + ...parent, + ...siblings.map((info) => { + const summary = summaryForInactiveSession(info); + return { + id: summary.sessionId, + ...(summary.sessionName ? { name: summary.sessionName } : {}), + depth: setDepth, + status: classifySessionRosterStatus(summary), + ...(summary.parentSessionPath + ? { parentSessionPath: canonicalSessionPath(summary.parentSessionPath) } + : {}), + }; + }), + ], { name, depth: setDepth, diff --git a/packages/coding-agent/test/agent-messages.test.ts b/packages/coding-agent/test/agent-messages.test.ts new file mode 100644 index 000000000..46d8c08de --- /dev/null +++ b/packages/coding-agent/test/agent-messages.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_FAMILY_REACH_ERROR, + assertAgentFamilyReach, + buildAgentFamilyRoster, +} from "../src/core/agent-messages.js"; + +describe("agent message structural family validation", () => { + it("excludes malformed family edges while retaining catalog-resolved depth-two siblings", () => { + const root = { id: "root", depth: 0, status: "running" as const, sessionPath: "/root" }; + const otherRoot = { id: "other-root", depth: 0, status: "running" as const, sessionPath: "/other" }; + const child = { + id: "child", + depth: 1, + status: "idle" as const, + parentSessionPath: "/root", + sessionPath: "/child", + }; + const malformedRoot = { + id: "malformed-root", + depth: 0, + status: "idle" as const, + parentSessionId: "root", + parentSessionPath: "/root", + }; + const contradictoryChild = { + id: "contradictory-child", + depth: 1, + status: "idle" as const, + parentSessionId: "root", + parentSessionPath: "/other", + }; + const depthSkippingDescendant = { + id: "depth-skipping-descendant", + depth: 2, + status: "idle" as const, + parentSessionId: "root", + parentSessionPath: "/root", + }; + const malformedDeepSiblingA = { + id: "malformed-deep-sibling-a", + depth: 2, + status: "idle" as const, + parentSessionId: "root", + parentSessionPath: "/root", + }; + const malformedDeepSiblingB = { + id: "malformed-deep-sibling-b", + depth: 2, + status: "idle" as const, + parentSessionId: "root", + parentSessionPath: "/root", + }; + const catalog = [ + root, + child, + malformedRoot, + contradictoryChild, + depthSkippingDescendant, + malformedDeepSiblingA, + malformedDeepSiblingB, + ]; + + // A root carrying a parent claim, contradictory dual claims, and a skipped + // depth must not become a direct family edge. + for (const malformed of [malformedRoot, contradictoryChild, depthSkippingDescendant]) { + expect(() => assertAgentFamilyReach(root, malformed, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + expect(() => assertAgentFamilyReach(malformed, root, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + } + expect(() => assertAgentFamilyReach(otherRoot, contradictoryChild, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + + // Two malformed depth-two rows that claim the root are not pseudo-siblings, + // and neither leaks into a roster. + expect(() => assertAgentFamilyReach(malformedDeepSiblingA, malformedDeepSiblingB, catalog)).toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + expect(buildAgentFamilyRoster(malformedDeepSiblingA, catalog).entries).toEqual([]); + expect(buildAgentFamilyRoster(root, catalog).entries.map((entry) => entry.id)).toEqual(["child"]); + + // A real depth-one parent in the supplied catalog restores legitimate + // depth-two siblings without weakening the malformed-edge exclusions above. + const deepParent = { id: "deep-parent", depth: 1, status: "running" as const, sessionPath: "/deep-parent" }; + const deepSiblingA = { + id: "deep-sibling-a", + depth: 2, + status: "idle" as const, + parentSessionId: "deep-parent", + parentSessionPath: "/deep-parent", + }; + const deepSiblingB = { + id: "deep-sibling-b", + depth: 2, + status: "idle" as const, + parentSessionPath: "/deep-parent", + }; + const deepCatalog = [deepParent, deepSiblingA, deepSiblingB]; + expect(() => assertAgentFamilyReach(deepSiblingA, deepSiblingB)).toThrow(AGENT_FAMILY_REACH_ERROR); + expect(assertAgentFamilyReach(deepSiblingA, deepSiblingB, deepCatalog)).toBe("sibling"); + expect(assertAgentFamilyReach(deepSiblingB, deepSiblingA, deepCatalog)).toBe("sibling"); + }); +}); diff --git a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts index 497c7e017..2ce419997 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -313,6 +313,45 @@ describe("daemon supervisor passive subagent topology", () => { ); }); + it("rejects taken saved sibling names for modern and legacy same-parent rows", () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-saved-sibling-parent-anchor-")); + tempDirs.push(directory); + const parentSessionPath = join(directory, "parent.jsonl"); + const base = { + cwd: directory, + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + parentSessionPath, + }; + const target = { ...base, id: "target", path: join(directory, "target.jsonl"), rlmDepth: 1 }; + const modernTaken = { + ...base, + id: "modern-taken", + path: join(directory, "modern-taken.jsonl"), + name: "taken", + rlmDepth: 1, + }; + const legacyTaken = { + ...base, + id: "legacy-taken", + path: join(directory, "legacy-taken.jsonl"), + name: "taken", + }; + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorInternals; + + for (const taken of [modernTaken, legacyTaken]) { + expect(() => supervisor.assertSavedSiblingNameAvailable([target, taken], target, "taken")).toThrow( + "an agent of that name already exists at depth 1 under this parent", + ); + } + }); + it("publishes an opening reservation before named create validation awaits", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-named-create-race-")); tempDirs.push(directory); From da683ec79f98fb41dcd75e54a42b2f4910ae0d62 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 05:38:34 -0700 Subject: [PATCH 4/8] feat(coding-agent): harden project MCP declarations --- .../coding-agent/src/cli/public-command.ts | 39 +- .../src/core/agent-session-services.ts | 166 +++--- packages/coding-agent/src/core/index.ts | 7 + .../coding-agent/src/core/mcp/mcp-manager.ts | 13 + .../mcp/mcp-project-declaration-reader.ts | 117 ++++ .../src/core/mcp/mcp-project-trust.ts | 74 ++- .../mcp/mcp-runtime-declaration-snapshot.ts | 63 ++- .../src/core/mcp/project-settings-openat.ts | 200 +++++++ .../src/core/mcp/project-trust-authority.ts | 183 ++++--- packages/coding-agent/src/core/sdk.ts | 500 ++++++++++-------- .../coding-agent/src/core/settings-manager.ts | 11 +- .../test/agent-session-services.test.ts | 161 +++++- .../test/mcp-declarations.test.ts | 268 ++++++++++ .../coding-agent/test/mcp-manager.test.ts | 19 + packages/coding-agent/test/mcp-probe.test.ts | 73 +++ .../test/mcp-public-composition.test.ts | 190 +++++++ .../mcp-runtime-declaration-snapshot.test.ts | 176 ++++++ .../test/project-settings-openat.test.ts | 171 ++++++ .../test/sdk-mcp-boundary.test.ts | 332 ++++++++++++ 19 files changed, 2349 insertions(+), 414 deletions(-) create mode 100644 packages/coding-agent/src/core/mcp/mcp-project-declaration-reader.ts create mode 100644 packages/coding-agent/src/core/mcp/project-settings-openat.ts create mode 100644 packages/coding-agent/test/mcp-declarations.test.ts create mode 100644 packages/coding-agent/test/mcp-probe.test.ts create mode 100644 packages/coding-agent/test/mcp-public-composition.test.ts create mode 100644 packages/coding-agent/test/mcp-runtime-declaration-snapshot.test.ts create mode 100644 packages/coding-agent/test/project-settings-openat.test.ts create mode 100644 packages/coding-agent/test/sdk-mcp-boundary.test.ts diff --git a/packages/coding-agent/src/cli/public-command.ts b/packages/coding-agent/src/cli/public-command.ts index 1889e902d..deb9dcd26 100644 --- a/packages/coding-agent/src/cli/public-command.ts +++ b/packages/coding-agent/src/cli/public-command.ts @@ -1,9 +1,12 @@ import chalk from "chalk"; import { APP_NAME, SELF_UPDATE_INTERACTIVE_CHILD_ENV } from "../config.js"; import { executeMcpDeclarationCommand, parseMcpDeclarationCommand } from "../core/mcp/mcp-declaration-command.js"; -import { createMcpProjectTrustAuthority } from "../core/index.js"; -import { admitProjectMcpDeclarations } from "../core/mcp/mcp-project-trust.js"; -import { SettingsManager, type Settings } from "../core/settings-manager.js"; +import { + admitGlobalMcpProjectDeclarations, + McpProjectDeclarationReader, +} from "../core/mcp/mcp-project-declaration-reader.js"; +import { releaseProjectMcpDeclarationAdmission } from "../core/mcp/mcp-project-trust.js"; +import { type Settings, SettingsManager } from "../core/settings-manager.js"; import { handlePackageCommand, isSelfUpdateSource } from "../package-manager-cli.js"; import { INTERNAL_RUNTIME_COMMAND_MARKER, parseArgs } from "./args.js"; import { @@ -151,7 +154,6 @@ async function runPublicCommand(args: string[]): Promise { } } - /** * Sole public-command composition point for project MCP policy. It receives a * SettingsManager already loaded by the CLI and reads only its global snapshot. @@ -163,17 +165,9 @@ export function composeMcpProjectDeclarationAdmission( workingDirectory: string, ) { if (command.scope !== "project") return undefined; - const globalPolicy = globalSettings.mcpProjectTrustPolicy; - const authority = createMcpProjectTrustAuthority({ - revision: typeof globalPolicy?.revision === "string" ? globalPolicy.revision : "", - allowedProjectDirectories: - Array.isArray(globalPolicy?.allowedProjectDirectories) && globalPolicy.allowedProjectDirectories.every((path) => typeof path === "string") - ? globalPolicy.allowedProjectDirectories - : [], - }); // The only raw-path authorization. Downstream receives no path or authority - // policy, only the opaque admission returned here. - return admitProjectMcpDeclarations(workingDirectory, authority); + // policy, only the opaque admission returned by the shared global composer. + return admitGlobalMcpProjectDeclarations(globalSettings, workingDirectory); } async function runMcpDeclarationCommand(args: string[]): Promise { @@ -188,11 +182,18 @@ async function runMcpDeclarationCommand(args: string[]): Promise; resourceLoaderOptions?: Omit; @@ -180,78 +188,110 @@ export async function createAgentSessionServices( const cwd = options.cwd; const agentDir = options.agentDir ?? getAgentDir(); const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); - const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); - const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); - - // MCP integrations: registers OAuth providers and gates the built-in - // integration skills by whether the user is logged in (enable-by-login). - const mcpManager = new McpManager({ - authStorage, - getUserServers: () => settingsManager.getMcpServers(), - }); - // refresh() resets the OAuth registry to built-ins; re-add user MCP providers too. - modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders()); - - const userExtensionFactories = options.resourceLoaderOptions?.extensionFactories ?? []; - // The built-in Herdr reporter defers to Herdr's own file-based integration - // when the loader actually loaded it; two reporters would race on the same - // pane. Deferral is late-bound to the loader's loaded paths (inline - // factories run after file extensions load), so a file that exists but is - // disabled or never discovered does not silence the built-in. - // noExtensions is a full opt-out: it disables the built-in reporter too, - // not just discovered extension files. - const skipHerdrReporter = options.noBuiltinHerdrReporter || options.resourceLoaderOptions?.noExtensions; - const builtinExtensionFactories = skipHerdrReporter - ? [] - : [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())]; - const resourceLoader: DefaultResourceLoader = new DefaultResourceLoader({ - ...(options.resourceLoaderOptions ?? {}), - extensionFactories: [...builtinExtensionFactories, ...userExtensionFactories], + // Compose the global-only admission and scoped reader before SettingsManager + // can load project state. Injected managers remain project-inert unless the + // caller carries an explicit opaque admission. + const { projectMcpAdmission, projectReader, releaseProjectMcpAdmission } = await composeMcpProjectDeclarationReader({ cwd, agentDir, - settingsManager, - extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(), + settingsManager: options.settingsManager, + projectMcpAdmission: options.projectMcpAdmission, }); - await resourceLoader.reload(); + try { + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); - const diagnostics: AgentSessionRuntimeDiagnostic[] = []; - if ( - !options.telemetryDisabled && - isTelemetryEnabled(settingsManager) && - !settingsManager.getTelemetryNoticeShown() - ) { - diagnostics.push({ - type: "info", - message: - "Prime Agent sends pseudonymous usage and performance metrics without prompts, responses, tool content, file paths, or repository data. Disable this with telemetry.enabled=false, PRIME_AGENT_TELEMETRY=0, DO_NOT_TRACK=1, or offline mode.", + // A single declaration-only snapshot is captured before any legacy manager + // behavior. The scoped reader validates its opaque admission around every + // project filesystem operation. + const runtimeMcpDeclarations = createMcpRuntimeDeclarationSnapshot({ + userDocument: settingsManager.getMcpDeclarationDocument("user"), + projectAdmission: projectMcpAdmission, + readProjectDocument: projectReader + ? () => { + try { + return projectReader.getDocument(); + } catch (error) { + // Root replacement/revocation during the scoped callback discards + // only the project contribution. Genuine still-authorized I/O or + // parse errors retain their normal failure behavior. + if (validateProjectMcpDeclarationAdmission(projectMcpAdmission).kind === "granted") throw error; + return undefined; + } + } + : undefined, }); - settingsManager.setTelemetryNoticeShown(true); - } - const extensionsResult = resourceLoader.getExtensions(); - for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { - try { - modelRegistry.registerProvider(name, config); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const mcpManager = new McpManager({ + authStorage, + getUserServers: () => settingsManager.getGlobalMcpServers(), + getRuntimeDeclarations: () => runtimeMcpDeclarations, + }); + // refresh() resets the OAuth registry to built-ins; re-add user MCP providers too. + modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders()); + + const userExtensionFactories = options.resourceLoaderOptions?.extensionFactories ?? []; + // The built-in Herdr reporter defers to Herdr's own file-based integration + // when the loader actually loaded it; two reporters would race on the same + // pane. Deferral is late-bound to the loader's loaded paths (inline + // factories run after file extensions load), so a file that exists but is + // disabled or never discovered does not silence the built-in. + // noExtensions is a full opt-out: it disables the built-in reporter too, + // not just discovered extension files. + const skipHerdrReporter = options.noBuiltinHerdrReporter || options.resourceLoaderOptions?.noExtensions; + const builtinExtensionFactories = skipHerdrReporter + ? [] + : [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())]; + const resourceLoader: DefaultResourceLoader = new DefaultResourceLoader({ + ...(options.resourceLoaderOptions ?? {}), + extensionFactories: [...builtinExtensionFactories, ...userExtensionFactories], + cwd, + agentDir, + settingsManager, + extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(), + }); + await resourceLoader.reload(); + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + if ( + !options.telemetryDisabled && + isTelemetryEnabled(settingsManager) && + !settingsManager.getTelemetryNoticeShown() + ) { diagnostics.push({ - type: "error", - message: `Extension "${extensionPath}" error: ${message}`, + type: "info", + message: + "Prime Agent sends pseudonymous usage and performance metrics without prompts, responses, tool content, file paths, or repository data. Disable this with telemetry.enabled=false, PRIME_AGENT_TELEMETRY=0, DO_NOT_TRACK=1, or offline mode.", }); + settingsManager.setTelemetryNoticeShown(true); } - } - extensionsResult.runtime.pendingProviderRegistrations = []; - diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); + const extensionsResult = resourceLoader.getExtensions(); + for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { + try { + modelRegistry.registerProvider(name, config); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ + type: "error", + message: `Extension "${extensionPath}" error: ${message}`, + }); + } + } + extensionsResult.runtime.pendingProviderRegistrations = []; + diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); - return { - cwd, - agentDir, - authStorage, - settingsManager, - modelRegistry, - resourceLoader, - mcpManager, - diagnostics, - }; + return { + cwd, + agentDir, + authStorage, + settingsManager, + modelRegistry, + resourceLoader, + mcpManager, + diagnostics, + }; + } finally { + releaseProjectMcpAdmission?.(); + } } /** diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index f30e669c1..ee9725629 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -77,6 +77,13 @@ export { type TurnStartEvent, type WorkingIndicatorOptions, } from "./extensions/index.js"; +export { + type CreateMcpRuntimeDeclarationSnapshotInput, + createMcpRuntimeDeclarationSnapshot, + type McpRuntimeDeclaration, + type McpRuntimeDeclarationSnapshot, + type McpRuntimeDeclarationSource, +} from "./mcp/mcp-runtime-declaration-snapshot.js"; export { createMcpProjectTrustAuthority, type McpProjectTrustAuthority, diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index b69fa2c0d..e497b188a 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -11,6 +11,7 @@ import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/ import type { AuthStorage } from "../auth-storage.js"; import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "../kernel/index.js"; import type { McpServerConfig } from "../settings-manager.js"; +import type { McpRuntimeDeclarationSnapshot } from "./mcp-runtime-declaration-snapshot.js"; export interface McpManagerOptions { authStorage: AuthStorage; @@ -18,6 +19,8 @@ export interface McpManagerOptions { getUserServers?: () => Record | undefined; /** Start an interactive host-side login for a server. Provided by the UI mode. */ beginLogin?: (server: string) => Promise; + /** Immutable declaration-only snapshot; never becomes integration config. */ + getRuntimeDeclarations?: () => McpRuntimeDeclarationSnapshot; } /** A resolved integration: a catalog/user entry plus its provider id. */ @@ -38,6 +41,7 @@ export class McpManager { private readonly authStorage: AuthStorage; private readonly getUserServers: () => Record | undefined; private readonly beginLogin?: (server: string) => Promise; + private readonly getRuntimeDeclarations: () => McpRuntimeDeclarationSnapshot | undefined; private integrations = new Map(); /** Provider ids we registered for user servers, so refresh can drop removed ones. */ private registeredUserProviderIds = new Set(); @@ -46,10 +50,19 @@ export class McpManager { this.authStorage = options.authStorage; this.getUserServers = options.getUserServers ?? (() => undefined); this.beginLogin = options.beginLogin; + this.getRuntimeDeclarations = options.getRuntimeDeclarations ?? (() => undefined); this.resolveIntegrations(); this.registerProviders(); } + /** + * Narrow internal declaration consumer. This deliberately returns no raw + * settings and is never consulted by OAuth, host handlers, or transports. + */ + getDeclarationSnapshot(): McpRuntimeDeclarationSnapshot | undefined { + return this.getRuntimeDeclarations(); + } + /** Re-read settings and re-register providers; call after a session reload. */ refresh(): void { this.resolveIntegrations(); diff --git a/packages/coding-agent/src/core/mcp/mcp-project-declaration-reader.ts b/packages/coding-agent/src/core/mcp/mcp-project-declaration-reader.ts new file mode 100644 index 000000000..2a3d32faa --- /dev/null +++ b/packages/coding-agent/src/core/mcp/mcp-project-declaration-reader.ts @@ -0,0 +1,117 @@ +import { type Settings, SettingsManager } from "../settings-manager.js"; +import type { McpDeclarationDocument } from "./mcp-declarations.js"; +import { + admitProjectMcpDeclarations, + type ProjectMcpDeclarationAdmission, + releaseProjectMcpDeclarationAdmission, + requireProjectMcpDeclarationAdmission, + validateProjectMcpDeclarationAdmission, +} from "./mcp-project-trust.js"; +import { ProjectSettingsOpenat } from "./project-settings-openat.js"; +import { createMcpProjectTrustAuthority } from "./project-trust-authority.js"; + +/** Compose an opaque project admission from a global-only policy snapshot. */ +export function admitGlobalMcpProjectDeclarations( + globalSettings: Pick | undefined, + cwd: string, +): ProjectMcpDeclarationAdmission | undefined { + const policy = globalSettings?.mcpProjectTrustPolicy; + if (!policy) return undefined; + const authority = createMcpProjectTrustAuthority({ + revision: typeof policy.revision === "string" ? policy.revision : "", + allowedProjectDirectories: + Array.isArray(policy.allowedProjectDirectories) && + policy.allowedProjectDirectories.every((path) => typeof path === "string") + ? policy.allowedProjectDirectories + : [], + }); + return admitProjectMcpDeclarations(cwd, authority); +} + +export interface McpProjectDeclarationReaderComposition { + projectMcpAdmission?: ProjectMcpDeclarationAdmission; + projectReader?: McpProjectDeclarationReader; + /** Present only for an admission made by this composition, never an injection. */ + releaseProjectMcpAdmission?: () => void; +} + +/** + * Builds the sole descriptor-relative declaration seam before ordinary project + * settings may be observed. Kernel discovery begins only after admission. + */ +export async function composeMcpProjectDeclarationReader(options: { + cwd: string; + agentDir: string; + settingsManager?: SettingsManager; + projectMcpAdmission?: ProjectMcpDeclarationAdmission; +}): Promise { + const globalSettings = options.settingsManager + ? undefined + : SettingsManager.loadGlobalSettings(options.cwd, options.agentDir); + const internallyAdmitted = options.projectMcpAdmission === undefined; + const projectMcpAdmission = + options.projectMcpAdmission ?? admitGlobalMcpProjectDeclarations(globalSettings, options.cwd); + if (!projectMcpAdmission) return {}; + try { + return { + projectMcpAdmission, + projectReader: await McpProjectDeclarationReader.create(projectMcpAdmission), + ...(internallyAdmitted + ? { releaseProjectMcpAdmission: () => releaseProjectMcpDeclarationAdmission(projectMcpAdmission) } + : {}), + }; + } catch (error) { + const stillGranted = validateProjectMcpDeclarationAdmission(projectMcpAdmission).kind === "granted"; + if (internallyAdmitted) releaseProjectMcpDeclarationAdmission(projectMcpAdmission); + if (stillGranted) throw error; + return {}; + } +} + +/** The project-MCP-only storage seam. Ordinary SettingsManager behavior remains unchanged. */ +export class McpProjectDeclarationReader { + private constructor( + private readonly admission: ProjectMcpDeclarationAdmission, + private readonly settings: ProjectSettingsOpenat, + ) {} + + static async create(admission: ProjectMcpDeclarationAdmission): Promise { + requireProjectMcpDeclarationAdmission(admission); + return new McpProjectDeclarationReader(admission, await ProjectSettingsOpenat.create(admission)); + } + + private assertAvailable(): void { + requireProjectMcpDeclarationAdmission(this.admission); + } + + getDocument(): McpDeclarationDocument { + this.assertAvailable(); + return this.settings.getDocument(); + } + + setDocument(document: McpDeclarationDocument): void { + this.assertAvailable(); + this.settings.setDocument(document); + } + + /** Adapter limited to executeMcpDeclarationCommand's three methods. */ + asCommandSettings(): { + getMcpDeclarationDocument(scope: "user" | "project"): McpDeclarationDocument; + setMcpDeclarationDocument(scope: "user" | "project", document: McpDeclarationDocument): void; + flush(): Promise; + } { + return { + getMcpDeclarationDocument: (scope) => { + if (scope !== "project") throw new Error("Project MCP declarations are unavailable."); + return this.getDocument(); + }, + setMcpDeclarationDocument: (scope, document) => { + if (scope !== "project") throw new Error("Project MCP declarations are unavailable."); + this.setDocument(document); + }, + flush: async () => { + this.assertAvailable(); + }, + }; + } +} diff --git a/packages/coding-agent/src/core/mcp/mcp-project-trust.ts b/packages/coding-agent/src/core/mcp/mcp-project-trust.ts index 9aad0325a..83e85c221 100644 --- a/packages/coding-agent/src/core/mcp/mcp-project-trust.ts +++ b/packages/coding-agent/src/core/mcp/mcp-project-trust.ts @@ -1,28 +1,40 @@ +import { emptyMcpDeclarationDocument, type McpDeclarationDocument } from "./mcp-declarations.js"; import type { McpProjectTrustAuthority, McpProjectTrustAuthorization, McpProjectTrustBinding, McpProjectTrustBindingValidation, } from "./project-trust-authority.js"; -import { isMcpProjectTrustAuthority } from "./project-trust-authority.js"; -import { emptyMcpDeclarationDocument, type McpDeclarationDocument } from "./mcp-declarations.js"; +import { + isMcpProjectTrustAuthority, + releaseMcpProjectTrustBinding, + withValidatedMcpProjectTrustBinding, +} from "./project-trust-authority.js"; -/** - * A branded, empty capability. Its authority/binding pair never appears on the - * object: membership is checked before that pair is ever dereferenced. - */ +/** A branded, empty capability whose trust pair stays module-private. */ export interface ProjectMcpDeclarationAdmission {} - interface AdmissionPair { readonly authority: McpProjectTrustAuthority; readonly binding: McpProjectTrustBinding; } - const admissions = new WeakSet(); +const releasedAdmissions = new WeakSet(); const admissionPairs = new WeakMap(); const DENIED: McpProjectTrustBindingValidation = Object.freeze({ kind: "denied" }); const GRANTED: McpProjectTrustBindingValidation = Object.freeze({ kind: "granted" }); +function pairFor(admission: ProjectMcpDeclarationAdmission | undefined): AdmissionPair | undefined { + // Membership strictly precedes map access: no forged accessor runs. + if ( + typeof admission !== "object" || + admission === null || + !admissions.has(admission) || + releasedAdmissions.has(admission) + ) + return undefined; + return admissionPairs.get(admission); +} + export function admitProjectMcpDeclarations( rawProjectDirectory: string, authority: McpProjectTrustAuthority | undefined, @@ -35,23 +47,28 @@ export function admitProjectMcpDeclarations( return undefined; } if (authorization.kind !== "granted") return undefined; - + // The authority minted and owns the descriptor before publishing this opaque admission. + if (withValidatedMcpProjectTrustBinding(authorization.binding, () => true) !== true) { + releaseMcpProjectTrustBinding(authorization.binding); + return undefined; + } const admission = Object.freeze(Object.create(null)); admissions.add(admission); admissionPairs.set(admission, Object.freeze({ authority, binding: authorization.binding })); return admission as ProjectMcpDeclarationAdmission; } -/** - * This membership test intentionally precedes the WeakMap read. A forged - * envelope cannot cause a supplied authority, binding, or accessor to be - * consulted. - */ +export function releaseProjectMcpDeclarationAdmission(admission: ProjectMcpDeclarationAdmission | undefined): void { + const pair = pairFor(admission); + if (!pair || !admission || releasedAdmissions.has(admission as object)) return; + releasedAdmissions.add(admission as object); + releaseMcpProjectTrustBinding(pair.binding); +} + export function validateProjectMcpDeclarationAdmission( admission: ProjectMcpDeclarationAdmission | undefined, ): McpProjectTrustBindingValidation { - if (typeof admission !== "object" || admission === null || !admissions.has(admission)) return DENIED; - const pair = admissionPairs.get(admission); + const pair = pairFor(admission); if (!pair) return DENIED; try { return pair.authority.validateBinding(pair.binding).kind === "granted" ? GRANTED : DENIED; @@ -60,12 +77,25 @@ export function validateProjectMcpDeclarationAdmission( } } +/** The only descriptor route from a genuine admission; it never reopens cwd. */ +export function withValidatedProjectMcpDeclarationAdmission( + admission: ProjectMcpDeclarationAdmission | undefined, + operation: (rootFd: number) => T, +): T | undefined { + const pair = pairFor(admission); + if (!pair || validateProjectMcpDeclarationAdmission(admission).kind !== "granted") return undefined; + try { + return withValidatedMcpProjectTrustBinding(pair.binding, operation); + } catch { + return undefined; + } +} + export function requireProjectMcpDeclarationAdmission( admission: ProjectMcpDeclarationAdmission | undefined, ): ProjectMcpDeclarationAdmission { - if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") { + if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") throw new Error("Project MCP declarations are unavailable."); - } return admission!; } @@ -73,17 +103,11 @@ export interface ProjectMcpDeclarations { document: McpDeclarationDocument; effective: boolean; } - -/** - * A denied, missing, stale, foreign, or forged capability makes declarations - * inert. The caller must validate before any project settings read or write. - */ export function resolveProjectMcpDeclarations( document: McpDeclarationDocument, admission: ProjectMcpDeclarationAdmission | undefined, ): ProjectMcpDeclarations { - if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") { + if (validateProjectMcpDeclarationAdmission(admission).kind !== "granted") return { document: emptyMcpDeclarationDocument(), effective: false }; - } return { document: structuredClone(document), effective: true }; } diff --git a/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts b/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts index 8f368f36f..ae3f57661 100644 --- a/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts +++ b/packages/coding-agent/src/core/mcp/mcp-runtime-declaration-snapshot.ts @@ -1,9 +1,6 @@ import { createHash } from "node:crypto"; -import { parseMcpDeclarationDocument, type McpDeclaration, type McpDeclarationDocument } from "./mcp-declarations.js"; -import { - type ProjectMcpDeclarationAdmission, - validateProjectMcpDeclarationAdmission, -} from "./mcp-project-trust.js"; +import { type McpDeclaration, type McpDeclarationDocument, parseMcpDeclarationDocument } from "./mcp-declarations.js"; +import { type ProjectMcpDeclarationAdmission, validateProjectMcpDeclarationAdmission } from "./mcp-project-trust.js"; export type McpRuntimeDeclarationSource = "user" | "project"; @@ -73,7 +70,9 @@ function parseDocument(value: unknown, source: McpRuntimeDeclarationSource): Mcp function snapshotRevision(declarations: readonly McpRuntimeDeclaration[]): string { const canonical = declarations.map(({ name, endpoint, enabled, source }) => [name, endpoint, enabled, source]); - return createHash("sha256").update(JSON.stringify([1, canonical])).digest("hex"); + return createHash("sha256") + .update(JSON.stringify([1, canonical])) + .digest("hex"); } /** @@ -89,22 +88,58 @@ export function createMcpRuntimeDeclarationSnapshot( const userNames = new Set(user.map((declaration) => declaration.name)); const userEndpoints = new Set(user.map((declaration) => declaration.endpoint)); + let project: McpRuntimeDeclaration[] | undefined; + if (input.readProjectDocument) { + // The first check is deliberately before the callback. A revoked grant + // therefore cannot cause even the scoped reader to touch project state. + if (validateProjectMcpDeclarationAdmission(input.projectAdmission).kind === "granted") { + const rawProjectDocument = input.readProjectDocument(); + // A root swap during the callback is fail-closed before parsing or use. + if (validateProjectMcpDeclarationAdmission(input.projectAdmission).kind === "granted") { + const parsedProject = parseDocument(rawProjectDocument, "project"); + // Parsing can invoke no declarations, but it is still between trust + // decisions: do not retain data if validity changed meanwhile. + if (validateProjectMcpDeclarationAdmission(input.projectAdmission).kind === "granted") { + project = parsedProject; + } + } + } + } if ( - input.readProjectDocument && - validateProjectMcpDeclarationAdmission(input.projectAdmission).kind === "granted" + project && + !project.some((declaration) => userNames.has(declaration.name) || userEndpoints.has(declaration.endpoint)) ) { - const project = parseDocument(input.readProjectDocument(), "project"); - if (!project.some((declaration) => userNames.has(declaration.name) || userEndpoints.has(declaration.endpoint))) { - selected.push(...project); - } + selected.push(...project); } selected.sort(compareNames); const declarations = Object.create(null) as Record; for (const declaration of selected) { Object.defineProperty(declarations, declaration.name, { - value: freezeDeclaration(declaration), enumerable: true, configurable: false, writable: false, + value: freezeDeclaration(declaration), + enumerable: true, + configurable: false, + writable: false, + }); + } + const snapshot = Object.freeze({ revision: snapshotRevision(selected), declarations: Object.freeze(declarations) }); + // Validate after freezing, immediately before publication. A swap at any + // point from callback entry through immutable-output construction leaves only + // the independent user contribution. + if (project && validateProjectMcpDeclarationAdmission(input.projectAdmission).kind !== "granted") { + const userDeclarations = Object.create(null) as Record; + for (const declaration of user) { + Object.defineProperty(userDeclarations, declaration.name, { + value: freezeDeclaration(declaration), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze({ + revision: snapshotRevision(user), + declarations: Object.freeze(userDeclarations), }); } - return Object.freeze({ revision: snapshotRevision(selected), declarations: Object.freeze(declarations) }); + return snapshot; } diff --git a/packages/coding-agent/src/core/mcp/project-settings-openat.ts b/packages/coding-agent/src/core/mcp/project-settings-openat.ts new file mode 100644 index 000000000..6677edcfb --- /dev/null +++ b/packages/coding-agent/src/core/mcp/project-settings-openat.ts @@ -0,0 +1,200 @@ +import { spawnSync } from "node:child_process"; +import { constants, realpathSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { ensureKernelPython } from "../kernel/bootstrap.js"; +import { type McpDeclarationDocument, parseMcpDeclarationDocument } from "./mcp-declarations.js"; +import { + type ProjectMcpDeclarationAdmission, + withValidatedProjectMcpDeclarationAdmission, +} from "./mcp-project-trust.js"; + +const MAX_BYTES = 128 * 1024; +const TIMEOUT_MS = 5_000; + +/** stdlib-only; receives a bounded action/document JSON on stdin and trusted root on fd 3. */ +const OPENAT_HELPER = String.raw`import fcntl, json, os, secrets, stat, sys +MAX=131072 +def reject(_=None): raise ValueError("invalid") +def unique(pairs): + d={} + for k,v in pairs: + if k in d: reject() + d[k]=v + return d +def load(raw): return json.loads(raw,parse_constant=reject,object_pairs_hook=unique) +def dflags(): return os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW +def directory(parent,name,create): + try: return os.open(name,dflags(),dir_fd=parent) + except FileNotFoundError: + if not create: raise + os.mkdir(name,0o700,dir_fd=parent) + return os.open(name,dflags(),dir_fd=parent) +def regular(parent,name,create=False): + flags=os.O_RDONLY|os.O_NOFOLLOW + if create: flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW + fd=os.open(name,flags,0o600,dir_fd=parent) + if not stat.S_ISREG(os.fstat(fd).st_mode): os.close(fd); reject() + return fd +def read(agent): + try: fd=regular(agent,"settings.json") + except FileNotFoundError: return {} + try: + data=bytearray() + while True: + part=os.read(fd,65536) + if not part: break + data.extend(part) + if len(data)>MAX: reject() + doc=load(bytes(data).decode("utf-8")) + if not isinstance(doc,dict): reject() + return doc + finally: os.close(fd) +def write(agent,declarations): + lock=temp=None; tempname=None + try: + lock=os.open("settings.json.lock",os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600,dir_fd=agent) + if not stat.S_ISREG(os.fstat(lock).st_mode): os.close(lock); lock=None; reject() + fcntl.flock(lock,fcntl.LOCK_EX) + doc=read(agent); doc["mcpDeclarations"]=declarations + raw=(json.dumps(doc,ensure_ascii=False,allow_nan=False,indent=2,separators=(",", ":"))+"\n").encode("utf-8") + if len(raw)>MAX: reject() + for _ in range(16): + candidate=".settings.json."+secrets.token_hex(16)+".tmp" + try: temp=regular(agent,candidate,True); tempname=candidate; break + except FileExistsError: pass + if temp is None: reject() + try: + offset=0 + while offsetMAX: reject() + request=load(raw.decode("utf-8")) + if not isinstance(request,dict) or set(request)-{"action","document"}: reject() + action=request.get("action") + if not stat.S_ISDIR(os.fstat(3).st_mode): reject() + prime=agent=None + try: + try: prime=directory(3,".prime",action=="write"); agent=directory(prime,"agent",action=="write") + except FileNotFoundError: + if action=="read": print('{"mcpDeclarations":null}'); return + raise + if action=="read": print(json.dumps({"mcpDeclarations":read(agent).get("mcpDeclarations")},ensure_ascii=False,allow_nan=False,separators=(",", ":"))) + elif action=="write" and "document" in request: write(agent,request["document"]); print("{}") + else: reject() + finally: + if agent is not None: os.close(agent) + if prime is not None: os.close(prime) +try: main() +except Exception: sys.stderr.write("project settings helper failed\n"); sys.exit(1) +`; + +function unavailable(): never { + // Never expose request, child stderr, settings, paths, or bootstrap diagnostics. + throw new Error("Project MCP declarations are unavailable."); +} + +/** The managed kernel resolver is host authority and is never called pre-admission. */ +export async function resolveTrustedProjectSettingsPython(): Promise { + const python = await ensureKernelPython(); + if (typeof python !== "string" || !isAbsolute(python)) unavailable(); + try { + const resolved = realpathSync.native(python); + const target = statSync(resolved); + if (!target.isFile() || (target.mode & constants.S_IXUSR) === 0) unavailable(); + return resolved; + } catch { + unavailable(); + } +} + +/** POSIX descriptor-relative project settings storage; it has no project path API. */ +export class ProjectSettingsOpenat { + private constructor( + private readonly admission: ProjectMcpDeclarationAdmission, + private readonly python: string, + ) {} + + static async create(admission: ProjectMcpDeclarationAdmission): Promise { + // This genuine capability check intentionally occurs before interpreter discovery. + if (withValidatedProjectMcpDeclarationAdmission(admission, () => true) !== true) unavailable(); + return new ProjectSettingsOpenat(admission, await resolveTrustedProjectSettingsPython()); + } + + private invoke(request: { action: "read" } | { action: "write"; document: McpDeclarationDocument }): unknown { + let input: string; + try { + input = JSON.stringify(request); + } catch { + unavailable(); + } + if (Buffer.byteLength(input) > MAX_BYTES) unavailable(); + try { + const result = withValidatedProjectMcpDeclarationAdmission(this.admission, (rootFd) => + spawnSync(this.python, ["-I", "-c", OPENAT_HELPER], { + input, + encoding: "utf8", + timeout: TIMEOUT_MS, + maxBuffer: MAX_BYTES, + stdio: ["pipe", "pipe", "pipe", rootFd], + shell: false, + }), + ); + if ( + !result || + result.error || + result.status !== 0 || + typeof result.stdout !== "string" || + Buffer.byteLength(result.stdout) > MAX_BYTES + ) + unavailable(); + try { + return JSON.parse(result.stdout); + } catch { + unavailable(); + } + } catch { + unavailable(); + } + } + + getDocument(): McpDeclarationDocument { + const response = this.invoke({ action: "read" }); + if ( + typeof response !== "object" || + response === null || + Array.isArray(response) || + !Object.hasOwn(response, "mcpDeclarations") + ) + unavailable(); + try { + return parseMcpDeclarationDocument((response as { mcpDeclarations: unknown }).mcpDeclarations); + } catch { + unavailable(); + } + } + + setDocument(document: McpDeclarationDocument): void { + let parsed: McpDeclarationDocument; + try { + parsed = parseMcpDeclarationDocument(document); + } catch { + unavailable(); + } + this.invoke({ action: "write", document: parsed! }); + } +} diff --git a/packages/coding-agent/src/core/mcp/project-trust-authority.ts b/packages/coding-agent/src/core/mcp/project-trust-authority.ts index 0f3fecdd9..337aa1f5b 100644 --- a/packages/coding-agent/src/core/mcp/project-trust-authority.ts +++ b/packages/coding-agent/src/core/mcp/project-trust-authority.ts @@ -1,19 +1,13 @@ import { createHash } from "node:crypto"; -import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; -/** - * Explicit policy input from a global, user-owned authority. Project settings - * are deliberately not an input to this factory or to the returned authority. - */ +/** Explicit policy input from a global, user-owned authority. */ export interface McpProjectTrustAuthorityInput { - /** Caller-owned policy revision, captured with the allowlist before use. */ readonly revision: string; - /** User-approved project directories. They must be exact canonical directories. */ readonly allowedProjectDirectories: readonly string[]; } -/** An opaque grant bound to a single project authority snapshot. */ declare const mcpProjectTrustBindingBrand: unique symbol; export interface McpProjectTrustBinding { readonly [mcpProjectTrustBindingBrand]: never; @@ -22,15 +16,8 @@ export interface McpProjectTrustBinding { export type McpProjectTrustAuthorization = | { readonly kind: "denied" } | { readonly kind: "granted"; readonly binding: McpProjectTrustBinding }; - -/** Safe, opaque verification result for a privileged boundary. */ export type McpProjectTrustBindingValidation = { readonly kind: "denied" } | { readonly kind: "granted" }; -/** - * A project trust authority exposes no policy, path, digest, revision, or - * boolean authorization surface. A revision is factory input only; consumers - * retain a grant and may only ask whether it is still valid. - */ export interface McpProjectTrustAuthority { authorizeProjectDirectory(projectDirectory: string): McpProjectTrustAuthorization; validateBinding(binding: unknown): McpProjectTrustBindingValidation; @@ -41,55 +28,51 @@ interface DirectoryIdentity { readonly device: string; readonly inode: string; } - interface BindingRecord { + readonly authority: McpProjectTrustAuthority; readonly revision: string; readonly digest: string; readonly identity: DirectoryIdentity; + readonly rootFd: number; } const DENIED: McpProjectTrustAuthorization = Object.freeze({ kind: "denied" }); const BINDING_DENIED: McpProjectTrustBindingValidation = Object.freeze({ kind: "denied" }); const BINDING_GRANTED: McpProjectTrustBindingValidation = Object.freeze({ kind: "granted" }); - -// Only authorities minted by this Core factory may cross privileged MCP seams. -// The registry remains module-private; callers receive only this narrow check. const genuineAuthorities = new WeakSet(); +const bindingRecords = new WeakMap(); +const releasedBindings = new WeakSet(); + +// A released binding unregisters before close so its finalizer can never close +// a descriptor number subsequently reused by Node. +const bindingFinalizer = new FinalizationRegistry((rootFd) => { + try { + closeSync(rootFd); + } catch { + /* best effort only */ + } +}); export function isMcpProjectTrustAuthority(value: unknown): value is McpProjectTrustAuthority { return typeof value === "object" && value !== null && genuineAuthorities.has(value); } -/** - * Reads a directory only when the supplied spelling is already its exact - * physical spelling. Relative paths, lexical aliases, symlinks (including - * ancestor symlinks), unreadable paths, and non-directories all fail closed. - */ -function exactDirectoryIdentity(path: string): DirectoryIdentity | undefined { - if (!isAbsolute(path) || resolve(path) !== path) { - return undefined; - } +function supportsRetainedDirectoryFd(): boolean { + return process.platform !== "win32" && constants.O_DIRECTORY !== undefined && constants.O_NOFOLLOW !== undefined; +} +function exactDirectoryIdentity(path: string): DirectoryIdentity | undefined { + if (!isAbsolute(path) || resolve(path) !== path) return undefined; try { const initial = lstatSync(path); - if (initial.isSymbolicLink() || !initial.isDirectory()) { - return undefined; - } + if (initial.isSymbolicLink() || !initial.isDirectory()) return undefined; accessSync(path, constants.R_OK | constants.X_OK); const canonicalPath = realpathSync.native(path); - if (canonicalPath !== path) { - return undefined; - } + if (canonicalPath !== path) return undefined; const canonical = statSync(canonicalPath, { bigint: true }); - if (!canonical.isDirectory()) { - return undefined; - } + if (!canonical.isDirectory()) return undefined; accessSync(canonicalPath, constants.R_OK | constants.X_OK); - return { - canonicalPath, - device: canonical.dev.toString(), - inode: canonical.ino.toString(), - }; + return { canonicalPath, device: canonical.dev.toString(), inode: canonical.ino.toString() }; } catch { return undefined; } @@ -102,17 +85,55 @@ function digestSnapshot(revision: string, directories: readonly DirectoryIdentit .update(directories.map(({ canonicalPath, device, inode }) => `${canonicalPath}\0${device}\0${inode}`).join("\0")) .digest("hex"); } - function sameIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { return left.canonicalPath === right.canonicalPath && left.device === right.device && left.inode === right.inode; } +function retainedDescriptorMatches(record: BindingRecord): boolean { + try { + const current = fstatSync(record.rootFd, { bigint: true }); + return ( + current.isDirectory() && + current.dev.toString() === record.identity.device && + current.ino.toString() === record.identity.inode + ); + } catch { + return false; + } +} /** - * Snapshots a global/user-owned allowlist before an MCP use. Construction is - * read-only and invalidates the complete policy on malformed, missing, - * unreadable, symlinked, or canonical-alias entries. No runtime settings, - * secrets, network, startup state, or ambient trust are consulted. + * Module-private binding ownership is the only route from a real admission to + * its root FD. It validates the retained descriptor and the current policy on + * both sides of the operation, preventing path ABA re-open races. */ +export function withValidatedMcpProjectTrustBinding( + binding: unknown, + operation: (rootFd: number) => T, +): T | undefined { + if (typeof binding !== "object" || binding === null || releasedBindings.has(binding)) return undefined; + const record = bindingRecords.get(binding); + if (!record || !retainedDescriptorMatches(record) || record.authority.validateBinding(binding).kind !== "granted") + return undefined; + const result = operation(record.rootFd); + return retainedDescriptorMatches(record) && record.authority.validateBinding(binding).kind === "granted" + ? result + : undefined; +} + +/** Explicit release for a real binding; foreign/duplicate releases are inert. */ +export function releaseMcpProjectTrustBinding(binding: unknown): void { + if (typeof binding !== "object" || binding === null || releasedBindings.has(binding)) return; + const record = bindingRecords.get(binding); + if (!record) return; + releasedBindings.add(binding); + bindingFinalizer.unregister(binding); + try { + closeSync(record.rootFd); + } catch { + /* idempotent and redacted */ + } +} + export function createMcpProjectTrustAuthority(input: McpProjectTrustAuthorityInput): McpProjectTrustAuthority { const revision = typeof input.revision === "string" ? input.revision : ""; const requestedDirectories = Array.isArray(input.allowedProjectDirectories) @@ -122,47 +143,75 @@ export function createMcpProjectTrustAuthority(input: McpProjectTrustAuthorityIn typeof directory === "string" ? exactDirectoryIdentity(directory) : undefined, ); const valid = + supportsRetainedDirectoryFd() && revision.length > 0 && identities.every((identity): identity is DirectoryIdentity => identity !== undefined) && new Set(identities.map((identity) => identity.canonicalPath)).size === identities.length; const snapshot = valid ? Object.freeze([...identities]) : Object.freeze([] as DirectoryIdentity[]); const snapshotDigest = digestSnapshot(revision, snapshot); const bindings = new WeakSet(); - const records = new WeakMap(); + const localRecords = new WeakMap(); const authority: McpProjectTrustAuthority = Object.freeze({ authorizeProjectDirectory(projectDirectory: string): McpProjectTrustAuthorization { const requested = typeof projectDirectory === "string" ? exactDirectoryIdentity(projectDirectory) : undefined; - if (!requested || !snapshot.some((approved) => sameIdentity(approved, requested))) { + if (!requested || !snapshot.some((approved) => sameIdentity(approved, requested))) return DENIED; + let rootFd: number | undefined; + try { + // This open is adjacent to the exact identity check. Its fstat must + // still be the authorized device/inode before a binding is minted. + rootFd = openSync( + requested.canonicalPath, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const opened = fstatSync(rootFd, { bigint: true }); + if ( + !opened.isDirectory() || + opened.dev.toString() !== requested.device || + opened.ino.toString() !== requested.inode + ) { + closeSync(rootFd); + return DENIED; + } + const binding = Object.freeze(Object.create(null)) as McpProjectTrustBinding; + const record: BindingRecord = Object.freeze({ + authority, + revision, + digest: snapshotDigest, + identity: requested, + rootFd, + }); + bindings.add(binding); + localRecords.set(binding, record); + bindingRecords.set(binding, record); + bindingFinalizer.register(binding, rootFd, binding); + return Object.freeze({ kind: "granted", binding }); + } catch { + if (rootFd !== undefined) + try { + closeSync(rootFd); + } catch { + /* redacted */ + } return DENIED; } - - // Module-private brands and records make this opaque grant runtime-unforgeable. - const binding = Object.freeze(Object.create(null)) as McpProjectTrustBinding; - bindings.add(binding); - records.set(binding, Object.freeze({ revision, digest: snapshotDigest, identity: requested })); - return Object.freeze({ kind: "granted", binding }); }, validateBinding(binding: unknown): McpProjectTrustBindingValidation { - if (typeof binding !== "object" || binding === null || !bindings.has(binding)) { + if (typeof binding !== "object" || binding === null || !bindings.has(binding) || releasedBindings.has(binding)) return BINDING_DENIED; - } - const record = records.get(binding); + const record = localRecords.get(binding); const currentSnapshot = snapshot.map(({ canonicalPath }) => exactDirectoryIdentity(canonicalPath)); - if (currentSnapshot.some((identity) => identity === undefined)) { - return BINDING_DENIED; - } - const currentIdentities = currentSnapshot as DirectoryIdentity[]; + if (currentSnapshot.some((identity) => identity === undefined)) return BINDING_DENIED; + const current = currentSnapshot as DirectoryIdentity[]; if ( !record || + !retainedDescriptorMatches(record) || record.revision !== revision || record.digest !== snapshotDigest || - !currentIdentities.every((identity, index) => sameIdentity(snapshot[index], identity)) || - digestSnapshot(revision, currentIdentities) !== snapshotDigest || + !current.every((identity, index) => sameIdentity(snapshot[index]!, identity)) || + digestSnapshot(revision, current) !== snapshotDigest || !snapshot.some((approved) => sameIdentity(approved, record.identity)) - ) { + ) return BINDING_DENIED; - } - return BINDING_GRANTED; }, }); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index fcd2e774c..4bee54de7 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -10,6 +10,12 @@ import type { AgentAutonomousConfig } from "./autonomous.js"; import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js"; import { McpManager } from "./mcp/mcp-manager.js"; +import { composeMcpProjectDeclarationReader } from "./mcp/mcp-project-declaration-reader.js"; +import { + type ProjectMcpDeclarationAdmission, + validateProjectMcpDeclarationAdmission, +} from "./mcp/mcp-project-trust.js"; +import { createMcpRuntimeDeclarationSnapshot } from "./mcp/mcp-runtime-declaration-snapshot.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import { findInitialModel } from "./model-resolver.js"; @@ -60,8 +66,13 @@ export interface CreateAgentSessionOptions extends AgentSessionCreationOptions { /** Resource loader. When omitted, DefaultResourceLoader is used. */ resourceLoader?: ResourceLoader; - /** MCP integration manager. When omitted, MCP host handlers are not wired. */ + /** + * MCP integration manager. When omitted, the SDK creates a global-only + * manager; an explicitly supplied manager is left untouched. + */ mcpManager?: McpManager; + /** Explicit opaque project declaration admission for an injected settings manager. */ + projectMcpAdmission?: ProjectMcpDeclarationAdmission; /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; @@ -163,247 +174,290 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const authStorage = options.authStorage ?? AuthStorage.create(authPath); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath); - const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); - const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); - - // Ensure MCP providers are registered and built-in MCP skills are gated by - // auth even on the bare SDK path (not just the CLI's createAgentSessionServices). - const mcpManager = - options.mcpManager ?? new McpManager({ authStorage, getUserServers: () => settingsManager.getMcpServers() }); - modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders()); - - if (!resourceLoader) { - resourceLoader = new DefaultResourceLoader({ - cwd, - agentDir, - settingsManager, - extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(), - }); - await resourceLoader.reload(); - time("resourceLoader.reload"); - } + // An explicit manager is caller authority: do not compose an admission, + // construct a scoped reader, or capture declarations on its behalf. + const projectMcpComposition = options.mcpManager + ? undefined + : await composeMcpProjectDeclarationReader({ + cwd, + agentDir, + settingsManager: options.settingsManager, + projectMcpAdmission: options.projectMcpAdmission, + }); + try { + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + + // Default SDK managers only consume global legacy integrations and one + // immutable declaration snapshot. Explicit managers remain untouched. + const runtimeMcpDeclarations: ReturnType | undefined = + options.mcpManager + ? undefined + : createMcpRuntimeDeclarationSnapshot({ + userDocument: settingsManager.getMcpDeclarationDocument("user"), + projectAdmission: projectMcpComposition?.projectMcpAdmission, + readProjectDocument: projectMcpComposition?.projectReader + ? () => { + try { + return projectMcpComposition.projectReader?.getDocument(); + } catch (error) { + if ( + validateProjectMcpDeclarationAdmission(projectMcpComposition.projectMcpAdmission) + .kind === "granted" + ) + throw error; + return undefined; + } + } + : undefined, + }); + const mcpManager = + options.mcpManager ?? + new McpManager({ + authStorage, + getUserServers: () => settingsManager.getGlobalMcpServers(), + getRuntimeDeclarations: () => runtimeMcpDeclarations!, + }); + modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders()); + + if (!resourceLoader) { + resourceLoader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(), + }); + await resourceLoader.reload(); + time("resourceLoader.reload"); + } - // Check if session has existing data to restore - const existingSession = sessionManager.buildSessionContext(); - const hasExistingSession = existingSession.messages.length > 0; - const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change"); - const hasServiceTierEntry = sessionManager.getBranch().some((entry) => entry.type === "service_tier_change"); + // Check if session has existing data to restore + const existingSession = sessionManager.buildSessionContext(); + const hasExistingSession = existingSession.messages.length > 0; + const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change"); + const hasServiceTierEntry = sessionManager.getBranch().some((entry) => entry.type === "service_tier_change"); - let model = options.model; - let modelFallbackMessage: string | undefined; + let model = options.model; + let modelFallbackMessage: string | undefined; - // If session has data, try to restore model from it - if (!model && hasExistingSession && existingSession.model) { - const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); - if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { - model = restoredModel; - } - if (!model) { - modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + // If session has data, try to restore model from it + if (!model && hasExistingSession && existingSession.model) { + const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); + if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { + model = restoredModel; + } + if (!model) { + modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + } } - } - // If still no model, use findInitialModel (checks settings default, then provider defaults) - if (!model) { - const result = await findInitialModel({ - scopedModels: [], - isContinuing: hasExistingSession, - defaultProvider: settingsManager.getDefaultProvider(), - defaultModelId: settingsManager.getDefaultModel(), - defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), - modelRegistry, - }); - model = result.model; + // If still no model, use findInitialModel (checks settings default, then provider defaults) if (!model) { - modelFallbackMessage = formatNoModelsAvailableMessage(); - } else if (modelFallbackMessage) { - modelFallbackMessage += `. Using ${model.provider}/${model.id}`; + const result = await findInitialModel({ + scopedModels: [], + isContinuing: hasExistingSession, + defaultProvider: settingsManager.getDefaultProvider(), + defaultModelId: settingsManager.getDefaultModel(), + defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelRegistry, + }); + model = result.model; + if (!model) { + modelFallbackMessage = formatNoModelsAvailableMessage(); + } else if (modelFallbackMessage) { + modelFallbackMessage += `. Using ${model.provider}/${model.id}`; + } } - } - - let thinkingLevel = options.thinkingLevel; - // If session has data, restore thinking level from it - if (thinkingLevel === undefined && hasExistingSession) { - thinkingLevel = hasThinkingEntry - ? (existingSession.thinkingLevel as ThinkingLevel) - : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); - } + let thinkingLevel = options.thinkingLevel; - // Fall back to settings default - if (thinkingLevel === undefined) { - thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; - } + // If session has data, restore thinking level from it + if (thinkingLevel === undefined && hasExistingSession) { + thinkingLevel = hasThinkingEntry + ? (existingSession.thinkingLevel as ThinkingLevel) + : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); + } - // Clamp to model capabilities - if (!model) { - thinkingLevel = "off"; - } else { - thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel; - } + // Fall back to settings default + if (thinkingLevel === undefined) { + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + } - const serviceTierPreference = - options.serviceTier ?? - (hasServiceTierEntry ? existingSession.serviceTier : settingsManager.getDefaultServiceTier()); - const serviceTier = - serviceTierPreference === "priority" && (!model || !supportsFastMode(model)) ? "default" : serviceTierPreference; - - const allowedToolNames = options.allowedToolNames ?? options.tools ?? (options.noTools === "all" ? [] : undefined); - const includeGoals = options.includeGoals ?? (options.tools !== undefined || options.noTools !== "all"); - const initialActiveToolNames: string[] = - options.initialActiveToolNames ?? (options.tools ? [...options.tools] : options.noTools ? [] : ["ipython"]); - - let agent: Agent; - - // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) - const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { - const converted = convertToLlm(messages); - // Check setting dynamically so mid-session changes take effect - if (!settingsManager.getBlockImages()) { - return converted; + // Clamp to model capabilities + if (!model) { + thinkingLevel = "off"; + } else { + thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel; } - // Filter out ImageContent from all messages, replacing with text placeholder - return converted.map((msg) => { - if (msg.role === "user" || msg.role === "toolResult") { - const content = msg.content; - if (Array.isArray(content)) { - const hasImages = content.some((c) => c.type === "image"); - if (hasImages) { - const filteredContent = content - .map((c) => - c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, - ) - .filter( - (c, i, arr) => - // Dedupe consecutive "Image reading is disabled." texts - !( - c.type === "text" && - c.text === "Image reading is disabled." && - i > 0 && - arr[i - 1].type === "text" && - (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." - ), - ); - return { ...msg, content: filteredContent }; + + const serviceTierPreference = + options.serviceTier ?? + (hasServiceTierEntry ? existingSession.serviceTier : settingsManager.getDefaultServiceTier()); + const serviceTier = + serviceTierPreference === "priority" && (!model || !supportsFastMode(model)) + ? "default" + : serviceTierPreference; + + const allowedToolNames = + options.allowedToolNames ?? options.tools ?? (options.noTools === "all" ? [] : undefined); + const includeGoals = options.includeGoals ?? (options.tools !== undefined || options.noTools !== "all"); + const initialActiveToolNames: string[] = + options.initialActiveToolNames ?? (options.tools ? [...options.tools] : options.noTools ? [] : ["ipython"]); + + let agent: Agent; + + // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) + const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + // Check setting dynamically so mid-session changes take effect + if (!settingsManager.getBlockImages()) { + return converted; + } + // Filter out ImageContent from all messages, replacing with text placeholder + return converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const hasImages = content.some((c) => c.type === "image"); + if (hasImages) { + const filteredContent = content + .map((c) => + c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, + ) + .filter( + (c, i, arr) => + // Dedupe consecutive "Image reading is disabled." texts + !( + c.type === "text" && + c.text === "Image reading is disabled." && + i > 0 && + arr[i - 1].type === "text" && + (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." + ), + ); + return { ...msg, content: filteredContent }; + } } } - } - return msg; - }); - }; - - const extensionRunnerRef: { current?: ExtensionRunner } = {}; - - agent = new Agent({ - initialState: { - systemPrompt: "", - model, - thinkingLevel, - serviceTier, - tools: [], - }, - convertToLlm: convertToLlmWithBlockImages, - streamFn: async (model, context, options) => { - const auth = await modelRegistry.getApiKeyAndHeaders(model); - if (!auth.ok) { - throw new Error(auth.error); - } - const providerRetrySettings = settingsManager.getProviderRetrySettings(); - return streamSimple(model, context, { - ...options, - apiKey: auth.apiKey, - timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, - maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, - maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, - headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined, + return msg; }); - }, - onPayload: async (payload, _model) => { - const runner = extensionRunnerRef.current; - if (!runner?.hasHandlers("before_provider_request")) { - return payload; + }; + + const extensionRunnerRef: { current?: ExtensionRunner } = {}; + + agent = new Agent({ + initialState: { + systemPrompt: "", + model, + thinkingLevel, + serviceTier, + tools: [], + }, + convertToLlm: convertToLlmWithBlockImages, + streamFn: async (model, context, options) => { + const auth = await modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) { + throw new Error(auth.error); + } + const providerRetrySettings = settingsManager.getProviderRetrySettings(); + return streamSimple(model, context, { + ...options, + apiKey: auth.apiKey, + timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, + maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, + headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined, + }); + }, + onPayload: async (payload, _model) => { + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("before_provider_request")) { + return payload; + } + return runner.emitBeforeProviderRequest(payload); + }, + onResponse: async (response, _model) => { + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("after_provider_response")) { + return; + } + await runner.emit({ + type: "after_provider_response", + status: response.status, + headers: response.headers, + }); + }, + sessionId: sessionManager.getSessionId(), + transformContext: async (messages) => { + const runner = extensionRunnerRef.current; + if (!runner) return messages; + return runner.emitContext(messages); + }, + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), + transport: settingsManager.getTransport(), + thinkingBudgets: settingsManager.getThinkingBudgets(), + maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, + }); + + // Restore messages if session has existing data + if (hasExistingSession) { + agent.state.messages = existingSession.messages; + if (!hasThinkingEntry) { + sessionManager.appendThinkingLevelChange(thinkingLevel); } - return runner.emitBeforeProviderRequest(payload); - }, - onResponse: async (response, _model) => { - const runner = extensionRunnerRef.current; - if (!runner?.hasHandlers("after_provider_response")) { - return; + } else { + // Save initial configuration for new sessions so it can be restored on resume. + if (model) { + sessionManager.appendModelChange(model.provider, model.id); } - await runner.emit({ - type: "after_provider_response", - status: response.status, - headers: response.headers, - }); - }, - sessionId: sessionManager.getSessionId(), - transformContext: async (messages) => { - const runner = extensionRunnerRef.current; - if (!runner) return messages; - return runner.emitContext(messages); - }, - steeringMode: settingsManager.getSteeringMode(), - followUpMode: settingsManager.getFollowUpMode(), - transport: settingsManager.getTransport(), - thinkingBudgets: settingsManager.getThinkingBudgets(), - maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, - }); - - // Restore messages if session has existing data - if (hasExistingSession) { - agent.state.messages = existingSession.messages; - if (!hasThinkingEntry) { sessionManager.appendThinkingLevelChange(thinkingLevel); } - } else { - // Save initial configuration for new sessions so it can be restored on resume. - if (model) { - sessionManager.appendModelChange(model.provider, model.id); + if (!hasServiceTierEntry) { + sessionManager.appendServiceTierChange(serviceTierPreference); } - sessionManager.appendThinkingLevelChange(thinkingLevel); - } - if (!hasServiceTierEntry) { - sessionManager.appendServiceTierChange(serviceTierPreference); - } - const session = new AgentSession({ - agent, - sessionManager, - settingsManager, - serviceTierPreference, - cwd, - // Only the explicit dir — the default may not match injected custom storage. - agentDir: options.agentDir, - scopedModels: options.scopedModels, - resourceLoader, - customTools: options.customTools, - modelRegistry, - mcpManager, - initialActiveToolNames, - allowedToolNames, - includeGoals, - includeCompactSkill: options.includeCompactSkill, - rlmHeartbeatController: options.rlmHeartbeatController, - agentMessageController: options.agentMessageController, - agentObserveController: options.agentObserveController, - extensionRunnerRef, - rlmDepth: options.rlmDepth, - rlmMaxDepth: options.rlmMaxDepth, - rlmSessionDir: options.rlmSessionDir, - rlmParentNodeId: options.rlmParentNodeId, - rlmParentAgent: options.rlmParentAgent, - subagentRuntimeHost: options.subagentRuntimeHost, - sessionStartEvent: options.sessionStartEvent, - prewarmIpythonKernel: options.prewarmIpythonKernel, - autonomous: options.autonomous, - serializedRefine: options.serializedRefine, - initialGoal: options.initialGoal, - }); - const extensionsResult = resourceLoader.getExtensions(); - - return { - session, - extensionsResult, - modelFallbackMessage, - }; + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + serviceTierPreference, + cwd, + // Only the explicit dir — the default may not match injected custom storage. + agentDir: options.agentDir, + scopedModels: options.scopedModels, + resourceLoader, + customTools: options.customTools, + modelRegistry, + mcpManager, + initialActiveToolNames, + allowedToolNames, + includeGoals, + includeCompactSkill: options.includeCompactSkill, + rlmHeartbeatController: options.rlmHeartbeatController, + agentMessageController: options.agentMessageController, + agentObserveController: options.agentObserveController, + extensionRunnerRef, + rlmDepth: options.rlmDepth, + rlmMaxDepth: options.rlmMaxDepth, + rlmSessionDir: options.rlmSessionDir, + rlmParentNodeId: options.rlmParentNodeId, + rlmParentAgent: options.rlmParentAgent, + subagentRuntimeHost: options.subagentRuntimeHost, + sessionStartEvent: options.sessionStartEvent, + prewarmIpythonKernel: options.prewarmIpythonKernel, + autonomous: options.autonomous, + serializedRefine: options.serializedRefine, + initialGoal: options.initialGoal, + }); + const extensionsResult = resourceLoader.getExtensions(); + + return { + session, + extensionsResult, + modelFallbackMessage, + }; + } finally { + projectMcpComposition?.releaseProjectMcpAdmission?.(); + } } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index e9dec5663..42eb0925c 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -5,9 +5,9 @@ import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { - parseMcpDeclarationDocument, type McpDeclarationDocument, type McpDeclarationScope, + parseMcpDeclarationDocument, } from "./mcp/mcp-declarations.js"; const RECENT_MODELS_LIMIT = 20; @@ -1237,10 +1237,19 @@ export class SettingsManager { return this.settings.enabledModels; } + /** Legacy/UI view: project values continue to override global values here. */ getMcpServers(): Record | undefined { return this.settings.mcpServers; } + /** + * Host-owned MCP integrations use only this global view. Project settings + * remain a UI/legacy overlay and cannot redirect host integration endpoints. + */ + getGlobalMcpServers(): Record | undefined { + return structuredClone(this.globalSettings.mcpServers); + } + /** Read one M01 declaration document without merging user and project scope. */ getMcpDeclarationDocument(scope: McpDeclarationScope): McpDeclarationDocument { const settings = scope === "user" ? this.globalSettings : this.projectSettings; diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index ae0ad6622..d2a42b1bb 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFauxProvider } from "@earendil-works/pi-ai"; @@ -7,8 +7,10 @@ import { AGENT_MESSAGE_SKILL_NAME, type AgentSessionMessageController } from ".. import { AGENT_OBSERVE_SKILL_NAME, type AgentObserveController } from "../src/core/agent-observe.js"; import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.js"; import { AuthStorage } from "../src/core/auth-storage.js"; +import { admitProjectMcpDeclarations } from "../src/core/mcp/mcp-project-trust.js"; +import { createMcpProjectTrustAuthority } from "../src/core/mcp/project-trust-authority.js"; import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.js"; import { createSyntheticSourceInfo } from "../src/core/source-info.js"; describe("createAgentSessionFromServices", () => { @@ -190,6 +192,161 @@ describe("createAgentSessionFromServices", () => { } }); + it("captures a frozen user and globally admitted project snapshot without exposing project endpoints", async () => { + const temporary = mkdtempSync(join(tmpdir(), "pi-session-project-snapshot-")); + const cwd = realpathSync.native(temporary); + const agentDir = join(temporary, "agent"); + mkdirSync(join(cwd, ".prime", "agent"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + cleanupPaths.push(temporary); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + mcpProjectTrustPolicy: { revision: "r1", allowedProjectDirectories: [cwd] }, + mcpDeclarations: { + version: 1, + servers: { user: { name: "user", url: "https://user.example/mcp", enabled: true } }, + }, + }), + ); + writeFileSync( + join(cwd, ".prime", "agent", "settings.json"), + JSON.stringify({ + mcpDeclarations: { + version: 1, + servers: { project: { name: "project", url: "https://project.example/mcp", enabled: true } }, + }, + }), + ); + const services = await createAgentSessionServices({ + cwd, + agentDir, + authStorage: AuthStorage.inMemory(), + resourceLoaderOptions: { noPromptTemplates: true, noThemes: true }, + }); + const snapshot = services.mcpManager.getDeclarationSnapshot()!; + expect(snapshot.declarations).toHaveProperty("user"); + expect(snapshot.declarations).toHaveProperty("project"); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.declarations)).toBe(true); + expect(Object.isFrozen(snapshot.declarations.project)).toBe(true); + expect(services.mcpManager.listStatus().map((status) => status.server)).not.toContain("project"); + expect(services.mcpManager.hostHandlers()).not.toHaveProperty("mcp.declarations"); + }); + + it("makes a root-revoked explicit admission inert before the service reader runs", async () => { + const temporary = mkdtempSync(join(tmpdir(), "pi-session-project-revoked-")); + const cwd = realpathSync.native(temporary); + const old = `${cwd}-old`; + const replacement = `${cwd}-replacement`; + mkdirSync(replacement); + cleanupPaths.push(temporary); + cleanupPaths.push(old); + const authority = createMcpProjectTrustAuthority({ revision: "r1", allowedProjectDirectories: [cwd] }); + const admission = admitProjectMcpDeclarations(cwd, authority)!; + const settingsManager = SettingsManager.inMemory({ + mcpDeclarations: { + version: 1, + servers: { user: { name: "user", url: "https://user.example/mcp", enabled: true } }, + }, + }); + renameSync(cwd, old); + renameSync(replacement, cwd); + const services = await createAgentSessionServices({ + cwd, + agentDir: cwd, + authStorage: AuthStorage.inMemory(), + settingsManager, + projectMcpAdmission: admission, + resourceLoaderOptions: { noPromptTemplates: true, noThemes: true }, + }); + const snapshot = services.mcpManager.getDeclarationSnapshot()!; + expect(snapshot.declarations).toHaveProperty("user"); + expect(snapshot.declarations).not.toHaveProperty("project"); + expect(services.mcpManager.listStatus().map((status) => status.server)).not.toContain("project"); + expect(services.mcpManager.hostHandlers()).not.toHaveProperty("mcp.declarations"); + }); + + it("fails closed for a mixed global project policy without reading project declarations", async () => { + const tempDir = join( + tmpdir(), + `pi-session-malformed-mcp-policy-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const agentDir = join(tempDir, "agent"); + mkdirSync(join(tempDir, ".prime", "agent"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + cleanupPaths.push(tempDir); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + mcpProjectTrustPolicy: { revision: "r1", allowedProjectDirectories: [tempDir, 7] }, + mcpDeclarations: { + version: 1, + servers: { "user-only": { name: "user-only", url: "https://user.example/mcp", enabled: true } }, + }, + }), + ); + writeFileSync( + join(tempDir, ".prime", "agent", "settings.json"), + JSON.stringify({ + mcpDeclarations: { + version: 1, + servers: { "project-only": { name: "project-only", url: "https://project.example/mcp", enabled: true } }, + }, + }), + ); + const services = await createAgentSessionServices({ + cwd: tempDir, + agentDir, + authStorage: AuthStorage.inMemory(), + resourceLoaderOptions: { noPromptTemplates: true, noThemes: true }, + }); + const declarations = services.mcpManager.getDeclarationSnapshot()!.declarations; + expect(declarations).toHaveProperty("user-only"); + expect(declarations).not.toHaveProperty("project-only"); + }); + + it("uses global MCP integrations while preserving the merged legacy view", async () => { + const tempDir = join(tmpdir(), `pi-session-global-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const stored: Record<"global" | "project", string | undefined> = { + global: JSON.stringify({ + mcpServers: { globalOnly: { type: "http", url: "https://global.example/mcp" } }, + mcpDeclarations: { + version: 1, + servers: { inert: { name: "inert", url: "https://declaration.example/mcp", enabled: true } }, + }, + }), + project: JSON.stringify({ mcpServers: { projectOnly: { type: "http", url: "https://project.example/mcp" } } }), + }; + const storage: SettingsStorage = { + withLock(scope, callback) { + const next = callback(stored[scope]); + if (next !== undefined) stored[scope] = next; + }, + }; + const settingsManager = SettingsManager.fromStorage(storage); + expect(settingsManager.getMcpServers()).toHaveProperty("projectOnly"); + expect(settingsManager.getGlobalMcpServers()).toHaveProperty("globalOnly"); + expect(settingsManager.getGlobalMcpServers()).not.toHaveProperty("projectOnly"); + + const services = await createAgentSessionServices({ + cwd: tempDir, + agentDir: tempDir, + authStorage: AuthStorage.inMemory(), + settingsManager, + resourceLoaderOptions: { noPromptTemplates: true, noThemes: true }, + }); + expect(services.mcpManager.listStatus().map((status) => status.server)).toContain("globalOnly"); + expect(services.mcpManager.listStatus().map((status) => status.server)).not.toContain("projectOnly"); + const declarations = services.mcpManager.getDeclarationSnapshot()!; + expect(declarations.declarations).toHaveProperty("inert"); + expect(Object.isFrozen(declarations)).toBe(true); + expect(Object.isFrozen(declarations.declarations.inert)).toBe(true); + expect(services.mcpManager.hostHandlers()).not.toHaveProperty("mcp.declarations"); + }); + it("hides daemon-backed orchestration skills unless their host bridges are available", async () => { const tempDir = join(tmpdir(), `pi-session-skills-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/mcp-declarations.test.ts b/packages/coding-agent/test/mcp-declarations.test.ts new file mode 100644 index 000000000..eec339bc0 --- /dev/null +++ b/packages/coding-agent/test/mcp-declarations.test.ts @@ -0,0 +1,268 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createMcpProjectTrustAuthority, type McpProjectTrustAuthority } from "../src/core/index.js"; +import { executeMcpDeclarationCommand, parseMcpDeclarationCommand } from "../src/core/mcp/mcp-declaration-command.js"; +import { + addMcpDeclaration, + emptyMcpDeclarationDocument, + parseMcpDeclarationDocument, +} from "../src/core/mcp/mcp-declarations.js"; +import { admitProjectMcpDeclarations, resolveProjectMcpDeclarations } from "../src/core/mcp/mcp-project-trust.js"; +import { redactMcpValue } from "../src/core/mcp/mcp-redaction.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; + +const projectDocument = { + version: 1 as const, + servers: { catalog: { name: "catalog", url: "https://catalog.test/mcp", enabled: true } }, +}; + +function fixture(): { directory: string; authority: McpProjectTrustAuthority; dispose(): void } { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "m01-project-"))); + return { + directory, + authority: createMcpProjectTrustAuthority({ revision: "test-policy", allowedProjectDirectories: [directory] }), + dispose: () => rmSync(directory, { recursive: true, force: true }), + }; +} + +describe("M01 declarative MCP contract", () => { + it("accepts only canonical credential-free declarations", () => { + const document = addMcpDeclaration(emptyMcpDeclarationDocument(), "public-docs", "HTTPS://Example.test:443/mcp"); + expect(document).toEqual({ + version: 1, + servers: { "public-docs": { name: "public-docs", url: "https://example.test/mcp", enabled: true } }, + }); + for (const url of [ + "https://user:secret@example.test/mcp", + "https://example.test/mcp?token=secret", + "https://example.test/mcp#token", + "file:///tmp/mcp", + ]) { + expect(() => addMcpDeclaration(emptyMcpDeclarationDocument(), "safe", url)).toThrow(); + } + expect(() => + parseMcpDeclarationDocument({ + version: 1, + servers: { x: { name: "x", url: "https://x.test", enabled: true, headers: {} } }, + }), + ).toThrow(); + }); + + it("rejects inherited, accessor, and symbol-bearing declaration data without reading it", () => { + const inherited = Object.create({ version: 1, servers: {} }); + const accessor = { version: 1, servers: {} as Record }; + Object.defineProperty(accessor, "servers", { + enumerable: true, + get() { + throw new Error("must not read accessor"); + }, + }); + const symbolBearing = { version: 1, servers: {} }; + Object.defineProperty(symbolBearing, Symbol("hidden"), { value: true, enumerable: false }); + expect(() => parseMcpDeclarationDocument(inherited)).toThrow(); + expect(() => parseMcpDeclarationDocument(accessor)).toThrow(); + expect(() => parseMcpDeclarationDocument(symbolBearing)).toThrow(); + }); + + it("parses non-starting command routing without touching settings or a runtime", () => { + expect(parseMcpDeclarationCommand(["add", "catalog", "https://catalog.test/mcp"])).toEqual({ + kind: "add", + scope: "user", + name: "catalog", + url: "https://catalog.test/mcp", + }); + expect(parseMcpDeclarationCommand(["preview", "catalog", "--project"])).toEqual({ + kind: "preview", + scope: "project", + name: "catalog", + }); + }); + + it("keeps user scope unchanged and routes a test only through an explicitly injected local transport", async () => { + const settings = SettingsManager.inMemory({ mcpDeclarations: projectDocument }); + const command = parseMcpDeclarationCommand(["test", "catalog"]); + await expect(executeMcpDeclarationCommand(command, settings)).rejects.toThrow("unavailable"); + const methods: string[] = []; + await expect( + executeMcpDeclarationCommand(command, settings, undefined, { + probeTransport: { + async open() { + return { request: async ({ method }) => void methods.push(method), close: () => undefined }; + }, + }, + }), + ).resolves.toEqual({ initialized: true, toolsListed: true }); + expect(methods).toEqual(["initialize", "tools/list"]); + }); + + it("rejects a structurally forged authority before authorization or project reads", () => { + let calls = 0; + const fake = { + authorizeProjectDirectory() { + calls++; + return { kind: "granted", binding: {} }; + }, + validateBinding() { + calls++; + return { kind: "granted" }; + }, + }; + const admission = admitProjectMcpDeclarations("/not-a-project", fake as never); + expect(admission).toBeUndefined(); + expect(calls).toBe(0); + }); + + it("makes exactly one raw-path authorization at admission and validates an opaque Core binding for project reads", () => { + const f = fixture(); + try { + // The actual Core authority returns a single opaque binding at admission. + // Subsequent uses only receive that binding; they have no raw path to reauthorize. + const admission = admitProjectMcpDeclarations(f.directory, f.authority); + expect(admission).toBeDefined(); + expect(resolveProjectMcpDeclarations(projectDocument, admission)).toEqual({ + document: projectDocument, + effective: true, + }); + expect(resolveProjectMcpDeclarations(projectDocument, admission)).toEqual({ + document: projectDocument, + effective: true, + }); + } finally { + f.dispose(); + } + }); + + it("denies missing, forged, stale, and foreign bindings before project reads, writes, or probe opens", async () => { + const f = fixture(); + const other = fixture(); + try { + const settings = SettingsManager.inMemory(); + const list = parseMcpDeclarationCommand(["list", "--project"]); + const add = parseMcpDeclarationCommand(["add", "new", "https://new.test/mcp", "--project"]); + const test = parseMcpDeclarationCommand(["test", "new", "--project"]); + const granted = admitProjectMcpDeclarations(f.directory, f.authority)!; + const foreign = { ...admitProjectMcpDeclarations(other.directory, other.authority)! }; + let forgedAuthorityCalls = 0; + const forged = { + get authority() { + forgedAuthorityCalls++; + return { validateBinding: () => ({ kind: "granted" }) }; + }, + get binding() { + forgedAuthorityCalls++; + return {}; + }, + }; + // Admissions are identity capabilities, so copied, forged, and foreign + // envelopes all fail before any caller-supplied member is read. + for (const admission of [undefined, { ...granted }, forged, foreign]) { + await expect(executeMcpDeclarationCommand(list, settings, admission)).rejects.toThrow( + "Project MCP declarations are unavailable.", + ); + await expect(executeMcpDeclarationCommand(add, settings, admission)).rejects.toThrow( + "Project MCP declarations are unavailable.", + ); + let opens = 0; + await expect( + executeMcpDeclarationCommand(test, settings, admission, { + probeTransport: { + async open() { + opens++; + throw new Error("must not open"); + }, + }, + }), + ).rejects.toThrow("Project MCP declarations are unavailable."); + expect(opens).toBe(0); + } + expect(forgedAuthorityCalls).toBe(0); + // A binding becomes stale once its bound directory no longer exists. + rmSync(f.directory, { recursive: true, force: true }); + await expect(executeMcpDeclarationCommand(list, settings, granted)).rejects.toThrow( + "Project MCP declarations are unavailable.", + ); + } finally { + f.dispose(); + other.dispose(); + } + }); + + it("opens a project test probe only after a validated Core grant", async () => { + const f = fixture(); + try { + const admission = admitProjectMcpDeclarations(f.directory, f.authority)!; + const settings = SettingsManager.inMemory(); + await executeMcpDeclarationCommand( + parseMcpDeclarationCommand(["add", "catalog", "https://catalog.test/mcp", "--project"]), + settings, + admission, + ); + const calls: string[] = []; + await expect( + executeMcpDeclarationCommand( + parseMcpDeclarationCommand(["test", "catalog", "--project"]), + settings, + admission, + { + probeTransport: { + async open() { + calls.push("open"); + return { + async request({ method }) { + calls.push(method); + }, + close() {}, + }; + }, + }, + }, + ), + ).resolves.toEqual({ initialized: true, toolsListed: true }); + expect(calls).toEqual(["open", "initialize", "tools/list"]); + } finally { + f.dispose(); + } + }); + + it("redacts credential-shaped fields and unsafe URLs before public rendering", () => { + const redacted = redactMcpValue({ + authorization: "Bearer secret", + headers: { "X-Api-Key": "secret" }, + nested: { token: "secret" }, + url: "https://u:secret@example.test/mcp", + }); + expect(redacted).toEqual({ + authorization: "", + headers: "", + nested: { token: "" }, + url: "", + }); + expect(JSON.stringify(redacted)).not.toContain("secret"); + }); + it("fails closed when a queued project write loses its approved root", async () => { + const f = fixture(); + const replacement = `${f.directory}-replacement`; + const old = `${f.directory}-old`; + const agentDir = mkdtempSync(join(tmpdir(), "m01-agent-")); + try { + const admission = admitProjectMcpDeclarations(f.directory, f.authority)!; + const settings = SettingsManager.create(f.directory, agentDir); + await executeMcpDeclarationCommand( + parseMcpDeclarationCommand(["add", "queued", "https://queued.test/mcp", "--project"]), + settings, + admission, + ); + renameSync(f.directory, old); + mkdirSync(replacement); + renameSync(replacement, f.directory); + await settings.flush(); + expect(existsSync(join(f.directory, ".prime", "agent", "settings.json"))).toBe(false); + } finally { + rmSync(old, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + f.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/mcp-manager.test.ts b/packages/coding-agent/test/mcp-manager.test.ts index 6f7d9f40d..dd5200ecd 100644 --- a/packages/coding-agent/test/mcp-manager.test.ts +++ b/packages/coding-agent/test/mcp-manager.test.ts @@ -5,6 +5,7 @@ import { getOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oau import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; import { McpManager } from "../src/core/mcp/mcp-manager.js"; +import { createMcpRuntimeDeclarationSnapshot } from "../src/core/mcp/mcp-runtime-declaration-snapshot.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import type { McpServerConfig } from "../src/core/settings-manager.js"; import { invokeHostRequest } from "./host-request-context.js"; @@ -170,6 +171,24 @@ describe("McpManager", () => { expect(getOAuthProvider("mcp:linear")).toBeUndefined(); }); + it("keeps declaration snapshots out of host handlers and legacy integrations", () => { + const snapshot = createMcpRuntimeDeclarationSnapshot({ + userDocument: { + version: 1, + servers: { inert: { name: "inert", url: "https://declaration.example/mcp", enabled: true } }, + }, + }); + const manager = new McpManager({ + authStorage, + getRuntimeDeclarations: () => snapshot, + }); + + expect(manager.getDeclarationSnapshot()).toBe(snapshot); + expect(manager.listStatus().map((status) => status.server)).not.toContain("inert"); + expect(manager.hostHandlers()).not.toHaveProperty("mcp.declarations"); + expect(manager.hostHandlers()).not.toHaveProperty("mcp.config.inert"); + }); + it("unregisters a user server's OAuth provider when it's removed on refresh()", () => { let servers: Record = { acme: { type: "http", url: "https://mcp.acme.test/mcp", oauth: true }, diff --git a/packages/coding-agent/test/mcp-probe.test.ts b/packages/coding-agent/test/mcp-probe.test.ts new file mode 100644 index 000000000..8e5f2e045 --- /dev/null +++ b/packages/coding-agent/test/mcp-probe.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { type McpProbeSession, type McpProbeTransport, runMcpDeclarationProbe } from "../src/core/mcp/mcp-probe.js"; + +const declaration = { name: "catalog", url: "https://catalog.test/mcp", enabled: true }; + +function fakeTransport(calls: string[], overrides: Partial = {}): McpProbeTransport { + return { + async open({ url }) { + calls.push(`open:${url}`); + return { + async request(request) { + calls.push(request.method); + }, + async close() { + calls.push("close"); + }, + ...overrides, + }; + }, + }; +} + +describe("M01 injected MCP probe", () => { + it("uses only initialize then tools/list and always closes the injected session", async () => { + const calls: string[] = []; + await expect(runMcpDeclarationProbe(declaration, fakeTransport(calls), { trusted: true })).resolves.toEqual({ + initialized: true, + toolsListed: true, + }); + expect(calls).toEqual(["open:https://catalog.test/mcp", "initialize", "tools/list", "close"]); + expect(calls.join(" ")).not.toContain("tools/call"); + }); + + it.each([ + ["disabled", { ...declaration, enabled: false }, { trusted: true }, "disabled"], + ["offline", declaration, { trusted: true, offline: true }, "offline"], + ["untrusted", declaration, {}, "not trusted"], + ] as const)("blocks %s before opening a transport", async (_name, input, options, message) => { + const calls: string[] = []; + await expect(runMcpDeclarationProbe(input, fakeTransport(calls), options)).rejects.toThrow(message); + expect(calls).toEqual([]); + }); + + it("redacts injected transport failures and closes the session", async () => { + const calls: string[] = []; + const transport = fakeTransport(calls, { + async request(request) { + calls.push(request.method); + throw new Error("https://alice:secret@catalog.test/mcp?token=secret"); + }, + }); + await expect(runMcpDeclarationProbe(declaration, transport, { trusted: true })).rejects.toThrow( + "MCP probe failed.", + ); + expect(calls).toEqual(["open:https://catalog.test/mcp", "initialize", "close"]); + }); + + it("aborts a hanging injected transport within its bounded timeout", async () => { + let aborted = false; + const transport: McpProbeTransport = { + open({ signal }) { + signal.addEventListener("abort", () => { + aborted = true; + }); + return new Promise(() => undefined); + }, + }; + await expect(runMcpDeclarationProbe(declaration, transport, { trusted: true, timeoutMs: 10 })).rejects.toThrow( + "timed out", + ); + expect(aborted).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/mcp-public-composition.test.ts b/packages/coding-agent/test/mcp-public-composition.test.ts new file mode 100644 index 000000000..3c0f2e88d --- /dev/null +++ b/packages/coding-agent/test/mcp-public-composition.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { composeMcpProjectDeclarationAdmission } from "../src/cli/public-command.js"; +import { executeMcpDeclarationCommand, parseMcpDeclarationCommand } from "../src/core/mcp/mcp-declaration-command.js"; +import { McpProjectDeclarationReader } from "../src/core/mcp/mcp-project-declaration-reader.js"; +import { SettingsManager, type SettingsScope, type SettingsStorage } from "../src/core/settings-manager.js"; + +const projectDocument = { + version: 1, + servers: { catalog: { name: "catalog", url: "https://catalog.test/mcp", enabled: true } }, +}; + +class TrackingStorage implements SettingsStorage { + readonly reads: Record = { global: 0, project: 0 }; + readonly writes: Record = { global: 0, project: 0 }; + constructor(private values: Record) {} + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { + this.reads[scope]++; + const next = fn(this.values[scope]); + if (next !== undefined) { + this.writes[scope]++; + this.values[scope] = next; + } + } + reset(): void { + this.reads.global = this.reads.project = this.writes.global = this.writes.project = 0; + } +} + +function directory(): { path: string; dispose(): void } { + const path = realpathSync.native(mkdtempSync(join(tmpdir(), "m01-public-"))); + return { path, dispose: () => rmSync(path, { recursive: true, force: true }) }; +} + +function storage(global: unknown, project: unknown): TrackingStorage { + return new TrackingStorage({ global: JSON.stringify(global), project: JSON.stringify(project) }); +} + +function admitFromGlobalStorage(store: TrackingStorage, workingDirectory: string) { + return composeMcpProjectDeclarationAdmission( + command(), + SettingsManager.loadGlobalSettingsFromStorage(store), + workingDirectory, + ); +} + +const command = () => parseMcpDeclarationCommand(["list", "--project"]); + +describe("M01 public command project policy composition", () => { + it("uses an exact global policy grant and carries only its opaque admission", async () => { + const d = directory(); + try { + const store = storage( + { mcpProjectTrustPolicy: { revision: "v1", allowedProjectDirectories: [d.path] } }, + { mcpDeclarations: projectDocument }, + ); + const admission = admitFromGlobalStorage(store, d.path); + expect(admission).toBeDefined(); + expect(store.reads).toEqual({ global: 1, project: 0 }); + // Only a grant permits construction of the full project-capable manager. + const settings = SettingsManager.fromStorage(store); + expect(store.reads).toEqual({ global: 2, project: 1 }); + await expect(executeMcpDeclarationCommand(command(), settings, admission)).resolves.toEqual(projectDocument); + } finally { + d.dispose(); + } + }); + + it.each([undefined, { revision: 1, allowedProjectDirectories: ["not-a-string-policy"] }])( + "denies missing or malformed global policy and project-local self-enable before project I/O", + async (policy) => { + const d = directory(); + try { + const store = storage( + { mcpProjectTrustPolicy: policy }, + { + mcpProjectTrustPolicy: { revision: "evil", allowedProjectDirectories: [d.path] }, + mcpDeclarations: projectDocument, + }, + ); + const admission = admitFromGlobalStorage(store, d.path); + expect(admission).toBeUndefined(); + // Production stops here: a deny must not construct SettingsManager.fromStorage. + expect(store.reads).toEqual({ global: 1, project: 0 }); + expect(store.writes.project).toBe(0); + // The downstream boundary independently remains inert if accidentally called. + const settings = SettingsManager.inMemory(); + let opens = 0; + await expect(executeMcpDeclarationCommand(command(), settings, admission)).rejects.toThrow( + "Project MCP declarations are unavailable.", + ); + await expect( + executeMcpDeclarationCommand( + parseMcpDeclarationCommand(["test", "catalog", "--project"]), + settings, + admission, + { + probeTransport: { + async open() { + opens++; + throw new Error("unexpected"); + }, + }, + }, + ), + ).rejects.toThrow("Project MCP declarations are unavailable."); + expect(opens).toBe(0); + } finally { + d.dispose(); + } + }, + ); + + it("denies an alias at composition and a replaced directory at the validated use boundary", async () => { + const d = directory(); + const old = `${d.path}-old`; + try { + const store = storage( + { mcpProjectTrustPolicy: { revision: "v1", allowedProjectDirectories: [d.path] } }, + { mcpDeclarations: projectDocument }, + ); + expect(admitFromGlobalStorage(store, `${d.path}/.`)).toBeUndefined(); + const admission = admitFromGlobalStorage(store, d.path); + const settings = SettingsManager.fromStorage(store); + renameSync(d.path, old); + mkdtempSync(d.path); + await expect(executeMcpDeclarationCommand(command(), settings, admission)).rejects.toThrow( + "Project MCP declarations are unavailable.", + ); + } finally { + rmSync(old, { recursive: true, force: true }); + d.dispose(); + } + }); + + it("authorizes once at composition and never reauthorizes during a later use", async () => { + const d = directory(); + try { + const store = storage( + { mcpProjectTrustPolicy: { revision: "v1", allowedProjectDirectories: [d.path] } }, + { mcpDeclarations: projectDocument }, + ); + const admission = admitFromGlobalStorage(store, d.path); + const settings = SettingsManager.fromStorage(store); + await expect(executeMcpDeclarationCommand(command(), settings, admission)).resolves.toEqual(projectDocument); + await expect(executeMcpDeclarationCommand(command(), settings, admission)).resolves.toEqual(projectDocument); + // execute has only the opaque admission argument; no raw path is available to reauthorize. + } finally { + d.dispose(); + } + }); + it("uses the scoped reader for admitted list and add while retaining ordinary project settings", async () => { + const d = directory(); + try { + const store = storage( + { mcpProjectTrustPolicy: { revision: "v1", allowedProjectDirectories: [d.path] } }, + { ordinary: { retained: true }, mcpDeclarations: projectDocument }, + ); + const admission = admitFromGlobalStorage(store, d.path)!; + const settingsPath = join(d.path, ".prime", "agent", "settings.json"); + // The file-backed reader is deliberately the project-MCP-only seam. + const { mkdirSync } = await import("node:fs"); + mkdirSync(join(d.path, ".prime", "agent"), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ ordinary: { retained: true }, mcpDeclarations: projectDocument }), + ); + const reader = await McpProjectDeclarationReader.create(admission); + const settings = reader.asCommandSettings(); + await expect(executeMcpDeclarationCommand(command(), settings as never, admission)).resolves.toEqual( + projectDocument, + ); + await expect( + executeMcpDeclarationCommand( + parseMcpDeclarationCommand(["add", "added", "https://added.test/mcp", "--project"]), + settings as never, + admission, + ), + ).resolves.toMatchObject({ name: "added" }); + const persisted = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(persisted.ordinary).toEqual({ retained: true }); + expect(persisted.mcpDeclarations.servers).toHaveProperty("catalog"); + expect(persisted.mcpDeclarations.servers).toHaveProperty("added"); + } finally { + d.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/mcp-runtime-declaration-snapshot.test.ts b/packages/coding-agent/test/mcp-runtime-declaration-snapshot.test.ts new file mode 100644 index 000000000..53f45b405 --- /dev/null +++ b/packages/coding-agent/test/mcp-runtime-declaration-snapshot.test.ts @@ -0,0 +1,176 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + admitProjectMcpDeclarations, + resolveProjectMcpDeclarations, + validateProjectMcpDeclarationAdmission, +} from "../src/core/mcp/mcp-project-trust.js"; +import { createMcpRuntimeDeclarationSnapshot } from "../src/core/mcp/mcp-runtime-declaration-snapshot.js"; +import { createMcpProjectTrustAuthority } from "../src/core/mcp/project-trust-authority.js"; + +const userDocument = { + version: 1 as const, + servers: { catalog: { name: "catalog", url: "HTTPS://Catalog.test:443/mcp", enabled: true } }, +}; +const projectDocument = { + version: 1 as const, + servers: { search: { name: "search", url: "https://search.test/mcp", enabled: false } }, +}; + +function directory(): { path: string; dispose(): void } { + const path = realpathSync.native(mkdtempSync(join(tmpdir(), "core-mcp-snapshot-"))); + return { path, dispose: () => rmSync(path, { recursive: true, force: true }) }; +} + +function admission(path: string) { + return admitProjectMcpDeclarations( + path, + createMcpProjectTrustAuthority({ revision: "global-r1", allowedProjectDirectories: [path] }), + ); +} + +describe("Core MCP declaration snapshot", () => { + it("creates a frozen declaration-only, code-point-ordered snapshot", () => { + const first = createMcpRuntimeDeclarationSnapshot({ userDocument }); + const second = createMcpRuntimeDeclarationSnapshot({ userDocument: structuredClone(userDocument) }); + expect(first).toEqual(second); + expect(first).toEqual({ + revision: expect.stringMatching(/^[a-f0-9]{64}$/), + declarations: { + catalog: { name: "catalog", endpoint: "https://catalog.test/mcp", enabled: true, source: "user" }, + }, + }); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.declarations)).toBe(true); + expect(Object.isFrozen(first.declarations.catalog)).toBe(true); + expect(Object.getPrototypeOf(first.declarations)).toBeNull(); + }); + + it("does not read project data for omitted, forged, accessor, or copied foreign admissions", () => { + const first = directory(); + const second = directory(); + try { + const genuine = admission(first.path)!; + const foreign = { ...admission(second.path)! }; + let reads = 0; + let fakeValidationCalls = 0; + const forged = { + get authority() { + fakeValidationCalls++; + return { validateBinding: () => ({ kind: "granted" }) }; + }, + get binding() { + fakeValidationCalls++; + return {}; + }, + }; + for (const projectAdmission of [undefined, {}, forged, foreign]) { + const snapshot = createMcpRuntimeDeclarationSnapshot({ + userDocument, + projectAdmission: projectAdmission as never, + readProjectDocument: () => { + reads++; + return projectDocument; + }, + }); + expect(snapshot.declarations).toEqual({ + catalog: { name: "catalog", endpoint: "https://catalog.test/mcp", enabled: true, source: "user" }, + }); + } + expect(reads).toBe(0); + expect(resolveProjectMcpDeclarations(projectDocument, forged as never)).toEqual({ + document: { version: 1, servers: {} }, + effective: false, + }); + expect(fakeValidationCalls).toBe(0); + expect(Object.getOwnPropertyNames(genuine)).toEqual([]); + expect(validateProjectMcpDeclarationAdmission(genuine)).toEqual({ kind: "granted" }); + } finally { + first.dispose(); + second.dispose(); + } + }); + + it("reads project data only after the opaque admission validates and makes collisions inert", () => { + const project = directory(); + try { + const granted = admission(project.path)!; + let reads = 0; + const selected = createMcpRuntimeDeclarationSnapshot({ + userDocument, + projectAdmission: granted, + readProjectDocument: () => { + reads++; + return projectDocument; + }, + }); + expect(reads).toBe(1); + expect(selected.declarations.search).toEqual({ + name: "search", + endpoint: "https://search.test/mcp", + enabled: false, + source: "project", + }); + + const colliding = createMcpRuntimeDeclarationSnapshot({ + userDocument, + projectAdmission: granted, + readProjectDocument: () => ({ + version: 1, + servers: { catalog: { name: "catalog", url: "https://other.test/mcp", enabled: true } }, + }), + }); + expect(Object.keys(colliding.declarations)).toEqual(["catalog"]); + } finally { + project.dispose(); + } + }); + + it("uses code-point order rather than locale collation", () => { + const original = Object.getOwnPropertyDescriptor(String.prototype, "localeCompare")!; + let localeCalls = 0; + Object.defineProperty(String.prototype, "localeCompare", { + ...original, + value() { + localeCalls++; + return 0; + }, + }); + try { + const snapshot = createMcpRuntimeDeclarationSnapshot({ + userDocument: { + version: 1, + servers: { + zebra: { name: "zebra", url: "https://z.test", enabled: true }, + apple: { name: "apple", url: "https://a.test", enabled: true }, + }, + }, + }); + expect(Object.keys(snapshot.declarations)).toEqual(["apple", "zebra"]); + expect(localeCalls).toBe(0); + } finally { + Object.defineProperty(String.prototype, "localeCompare", original); + } + }); + + it("rejects accessor, inherited, and locale-sensitive parser inputs without reading inherited data", () => { + const inherited = Object.create({ version: 1, servers: {} }); + const accessor = { version: 1, servers: {} as Record }; + Object.defineProperty(accessor.servers, "catalog", { + enumerable: true, + get() { + throw new Error("must not get accessor"); + }, + }); + expect(() => createMcpRuntimeDeclarationSnapshot({ userDocument: inherited })).toThrow(); + expect(() => createMcpRuntimeDeclarationSnapshot({ userDocument: accessor })).toThrow(); + // Names are ASCII-only. Locale collation is never consulted by selection. + expect(() => + createMcpRuntimeDeclarationSnapshot({ + userDocument: { version: 1, servers: { İ: { name: "İ", url: "https://x.test", enabled: true } } }, + }), + ).toThrow(); + }); +}); diff --git a/packages/coding-agent/test/project-settings-openat.test.ts b/packages/coding-agent/test/project-settings-openat.test.ts new file mode 100644 index 000000000..fada6e0a3 --- /dev/null +++ b/packages/coding-agent/test/project-settings-openat.test.ts @@ -0,0 +1,171 @@ +import * as childProcess from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const childProcessSpies = vi.hoisted(() => ({ + spawnSync: vi.fn(), + originalSpawnSync: undefined as typeof import("node:child_process").spawnSync | undefined, +})); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + childProcessSpies.originalSpawnSync = actual.spawnSync; + childProcessSpies.spawnSync.mockImplementation(actual.spawnSync); + return { ...actual, spawnSync: childProcessSpies.spawnSync }; +}); + +import { McpProjectDeclarationReader } from "../src/core/mcp/mcp-project-declaration-reader.js"; +import { + admitProjectMcpDeclarations, + releaseProjectMcpDeclarationAdmission, +} from "../src/core/mcp/mcp-project-trust.js"; +import { createMcpProjectTrustAuthority } from "../src/core/mcp/project-trust-authority.js"; + +const cleanup: string[] = []; +const unavailable = "Project MCP declarations are unavailable."; + +function root(): string { + const path = mkdtempSync(join(realpathSync.native(tmpdir()), "project-openat-")); + cleanup.push(path); + return path; +} +function admission(path: string) { + const grant = admitProjectMcpDeclarations( + path, + createMcpProjectTrustAuthority({ revision: "r1", allowedProjectDirectories: [path] }), + ); + expect(grant).toBeDefined(); + return grant!; +} +function document() { + return { version: 1 as const, servers: {} }; +} + +afterEach(() => { + vi.restoreAllMocks(); + childProcessSpies.spawnSync.mockClear(); + while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); +}); + +describe("project settings openat", () => { + it("creates only below its retained root and preserves ordinary settings", async () => { + const cwd = root(); + const grant = admission(cwd); + try { + const reader = await McpProjectDeclarationReader.create(grant); + reader.setDocument(document()); + writeFileSync(join(cwd, ".prime", "agent", "settings.json"), JSON.stringify({ ordinary: { kept: true } })); + reader.setDocument(document()); + expect(JSON.parse(readFileSync(join(cwd, ".prime", "agent", "settings.json"), "utf8"))).toMatchObject({ + ordinary: { kept: true }, + mcpDeclarations: { version: 1 }, + }); + } finally { + releaseProjectMcpDeclarationAdmission(grant); + } + }); + + it("fails closed for component and leaf symlinks", async () => { + for (const kind of ["prime", "agent", "leaf"] as const) { + const cwd = root(); + const outside = root(); + if (kind === "prime") { + symlinkSync(outside, join(cwd, ".prime"), "dir"); + } else { + mkdirSync(join(cwd, ".prime", "agent"), { recursive: true }); + if (kind === "agent") { + rmSync(join(cwd, ".prime", "agent"), { recursive: true }); + symlinkSync(outside, join(cwd, ".prime", "agent"), "dir"); + } else { + symlinkSync(join(outside, "settings.json"), join(cwd, ".prime", "agent", "settings.json")); + } + } + const grant = admission(cwd); + try { + const reader = await McpProjectDeclarationReader.create(grant); + expect(() => reader.getDocument()).toThrow(unavailable); + expect(() => reader.setDocument(document())).toThrow(unavailable); + } finally { + releaseProjectMcpDeclarationAdmission(grant); + } + } + }); + + it("fails closed for malformed, duplicate, and non-finite settings without exposing them", async () => { + for (const raw of ["{", '{"ordinary":1,"ordinary":2}', '{"ordinary":NaN}', '{"ordinary":Infinity}']) { + const cwd = root(); + mkdirSync(join(cwd, ".prime", "agent"), { recursive: true }); + writeFileSync(join(cwd, ".prime", "agent", "settings.json"), raw); + const grant = admission(cwd); + try { + const reader = await McpProjectDeclarationReader.create(grant); + expect(() => reader.getDocument()).toThrow(unavailable); + expect(() => reader.setDocument(document())).toThrow(unavailable); + } finally { + releaseProjectMcpDeclarationAdmission(grant); + } + } + }); + + it("keeps a permanently replaced root untouched after validation", async () => { + const cwd = root(); + const old = `${cwd}-old`; + const replacement = `${cwd}-replacement`; + cleanup.push(old, replacement); + mkdirSync(join(replacement, ".prime", "agent"), { recursive: true }); + writeFileSync( + join(replacement, ".prime", "agent", "settings.json"), + JSON.stringify({ replacementSentinel: true }), + ); + const grant = admission(cwd); + const realSpawnSync = childProcessSpies.originalSpawnSync!; + childProcessSpies.spawnSync.mockImplementationOnce(((...args: Parameters) => { + renameSync(cwd, old); + renameSync(replacement, cwd); + return realSpawnSync(...args); + }) as typeof childProcess.spawnSync); + try { + const reader = await McpProjectDeclarationReader.create(grant); + expect(() => reader.setDocument(document())).toThrow(unavailable); + expect(childProcessSpies.spawnSync).toHaveBeenCalledOnce(); + const options = childProcessSpies.spawnSync.mock.calls[0]![2]! as { shell?: unknown; stdio?: unknown }; + expect(options.shell).toBe(false); + expect((options.stdio as unknown[])[3]).toEqual(expect.any(Number)); + expect(JSON.parse(readFileSync(join(cwd, ".prime", "agent", "settings.json"), "utf8"))).toEqual({ + replacementSentinel: true, + }); + } finally { + releaseProjectMcpDeclarationAdmission(grant); + } + }); + + it("bounds helper input/output, redacts all child failure detail, and closes a released grant", async () => { + const cwd = root(); + const grant = admission(cwd); + try { + const reader = await McpProjectDeclarationReader.create(grant); + const spawn = vi.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 1, + stdout: "secret settings path and child stderr", + stderr: "secret", + } as ReturnType); + expect(() => reader.getDocument()).toThrow(unavailable); + expect(() => reader.getDocument()).not.toThrow(/secret/); + expect(spawn.mock.calls[0]![1]).toEqual(["-I", "-c", expect.any(String)]); + releaseProjectMcpDeclarationAdmission(grant); + expect(() => reader.getDocument()).toThrow(unavailable); + } finally { + releaseProjectMcpDeclarationAdmission(grant); + } + }); +}); diff --git a/packages/coding-agent/test/sdk-mcp-boundary.test.ts b/packages/coding-agent/test/sdk-mcp-boundary.test.ts new file mode 100644 index 000000000..5e9735568 --- /dev/null +++ b/packages/coding-agent/test/sdk-mcp-boundary.test.ts @@ -0,0 +1,332 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { createExtensionRuntime } from "../src/core/extensions/loader.js"; +import { McpManager } from "../src/core/mcp/mcp-manager.js"; +import { admitProjectMcpDeclarations } from "../src/core/mcp/mcp-project-trust.js"; +import { createMcpProjectTrustAuthority } from "../src/core/mcp/project-trust-authority.js"; +import type { ResourceLoader } from "../src/core/resource-loader.js"; +import { createAgentSession } from "../src/core/sdk.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { SettingsManager, type SettingsScope, type SettingsStorage } from "../src/core/settings-manager.js"; +import { invokeHostRequest } from "./host-request-context.js"; + +const mcpBoundarySpies = vi.hoisted(() => ({ + composeProjectReader: vi.fn(), + createScopedReader: vi.fn(), + readScopedDocument: vi.fn(), + createRuntimeSnapshot: vi.fn(), + ensureKernelPython: vi.fn(), + spawnSync: vi.fn(), +})); + +vi.mock("../src/core/kernel/bootstrap.js", async (importOriginal) => { + const actual = await importOriginal(); + mcpBoundarySpies.ensureKernelPython.mockImplementation(actual.ensureKernelPython); + return { ...actual, ensureKernelPython: mcpBoundarySpies.ensureKernelPython }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + mcpBoundarySpies.spawnSync.mockImplementation(actual.spawnSync); + return { ...actual, spawnSync: mcpBoundarySpies.spawnSync }; +}); + +vi.mock("../src/core/mcp/mcp-project-declaration-reader.js", async (importOriginal) => { + const actual = await importOriginal(); + const createScopedReader = actual.McpProjectDeclarationReader.create; + const readScopedDocument = actual.McpProjectDeclarationReader.prototype.getDocument; + mcpBoundarySpies.composeProjectReader.mockImplementation(actual.composeMcpProjectDeclarationReader); + mcpBoundarySpies.createScopedReader.mockImplementation(createScopedReader); + mcpBoundarySpies.readScopedDocument.mockImplementation(readScopedDocument); + Object.defineProperty(actual.McpProjectDeclarationReader, "create", { + configurable: true, + value: mcpBoundarySpies.createScopedReader, + }); + Object.defineProperty(actual.McpProjectDeclarationReader.prototype, "getDocument", { + configurable: true, + value: mcpBoundarySpies.readScopedDocument, + }); + return { ...actual, composeMcpProjectDeclarationReader: mcpBoundarySpies.composeProjectReader }; +}); + +vi.mock("../src/core/mcp/mcp-runtime-declaration-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); + mcpBoundarySpies.createRuntimeSnapshot.mockImplementation(actual.createMcpRuntimeDeclarationSnapshot); + return { ...actual, createMcpRuntimeDeclarationSnapshot: mcpBoundarySpies.createRuntimeSnapshot }; +}); + +const cleanup: string[] = []; + +const resourceLoader: ResourceLoader = { + getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }), + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => undefined, + getAppendSystemPrompt: () => [], + extendResources: () => {}, + reload: async () => {}, +}; + +function manager(session: unknown): McpManager { + return (session as { _mcpManager: McpManager })._mcpManager; +} + +function server(name: string, url: string) { + return { [name]: { type: "http" as const, url } }; +} + +class MemoryStorage implements SettingsStorage { + constructor(private readonly values: Record) {} + withLock(scope: SettingsScope, callback: (current: string | undefined) => string | undefined): void { + const next = callback(this.values[scope]); + if (next !== undefined) this.values[scope] = next; + } +} + +async function sdk(options: NonNullable[0]>) { + return createAgentSession({ ...options, resourceLoader, sessionManager: SessionManager.inMemory(options.cwd) }); +} + +beforeEach(() => { + mcpBoundarySpies.composeProjectReader.mockClear(); + mcpBoundarySpies.createScopedReader.mockClear(); + mcpBoundarySpies.readScopedDocument.mockClear(); + mcpBoundarySpies.createRuntimeSnapshot.mockClear(); + mcpBoundarySpies.ensureKernelPython.mockClear(); + mcpBoundarySpies.spawnSync.mockClear(); +}); + +afterEach(() => { + while (cleanup.length > 0) { + const path = cleanup.pop(); + if (path && existsSync(path)) rmSync(path, { recursive: true, force: true }); + } +}); + +describe("SDK MCP boundary", () => { + it("uses only global legacy integrations and publishes one frozen globally admitted declaration snapshot", async () => { + const root = mkdtempSync(join(tmpdir(), "sdk-mcp-boundary-")); + const cwd = realpathSync.native(root); + const agentDir = join(root, "agent"); + cleanup.push(root); + mkdirSync(join(cwd, ".prime", "agent"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + mcpProjectTrustPolicy: { revision: "r1", allowedProjectDirectories: [cwd] }, + mcpServers: server("global", "https://global.example/mcp"), + mcpDeclarations: { + version: 1, + servers: { user: { name: "user", url: "https://user.example/mcp", enabled: true } }, + }, + }), + ); + writeFileSync( + join(cwd, ".prime", "agent", "settings.json"), + JSON.stringify({ + mcpServers: server("project", "https://project.example/mcp"), + mcpDeclarations: { + version: 1, + servers: { project: { name: "project", url: "https://project.example/mcp", enabled: true } }, + }, + }), + ); + + const { session } = await sdk({ cwd, agentDir, authStorage: AuthStorage.inMemory() }); + try { + const mcp = manager(session); + expect(mcp.listStatus().map((status) => status.server)).toContain("global"); + expect(mcp.listStatus().map((status) => status.server)).not.toContain("project"); + const handlers = mcp.hostHandlers(); + expect(await invokeHostRequest(handlers["mcp.config"] as never, { server: "global" })).toEqual({ + url: "https://global.example/mcp", + }); + expect(await invokeHostRequest(handlers["mcp.config"] as never, { server: "project" })).toEqual({}); + expect(handlers).not.toHaveProperty("mcp.declarations"); + const snapshot = mcp.getDeclarationSnapshot()!; + expect(snapshot.declarations).toHaveProperty("user"); + expect(snapshot.declarations).toHaveProperty("project"); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.declarations)).toBe(true); + expect(Object.isFrozen(snapshot.declarations.project)).toBe(true); + } finally { + session.dispose(); + } + }); + + it("makes missing, forged, and root-swapped admissions user-only for injected settings", async () => { + const root = mkdtempSync(join(tmpdir(), "sdk-mcp-inert-")); + const cwd = realpathSync.native(root); + const replacement = `${cwd}-replacement`; + const old = `${cwd}-old`; + cleanup.push(old, replacement, root); + mkdirSync(replacement); + const authority = createMcpProjectTrustAuthority({ revision: "r1", allowedProjectDirectories: [cwd] }); + const admission = admitProjectMcpDeclarations(cwd, authority)!; + const settingsManager = SettingsManager.fromStorage( + new MemoryStorage({ + global: JSON.stringify({ + mcpServers: server("global", "https://global.example/mcp"), + mcpDeclarations: { + version: 1, + servers: { user: { name: "user", url: "https://user.example/mcp", enabled: true } }, + }, + }), + project: JSON.stringify({ + mcpServers: server("project", "https://project.example/mcp"), + mcpDeclarations: { + version: 1, + servers: { project: { name: "project", url: "https://project.example/mcp", enabled: true } }, + }, + }), + }), + ); + for (const projectMcpAdmission of [undefined, {} as never]) { + const { session } = await sdk({ + cwd, + agentDir: cwd, + settingsManager, + authStorage: AuthStorage.inMemory(), + projectMcpAdmission, + }); + try { + const mcp = manager(session); + expect(mcp.getDeclarationSnapshot()!.declarations).toHaveProperty("user"); + expect(mcp.getDeclarationSnapshot()!.declarations).not.toHaveProperty("project"); + expect(mcp.listStatus().map((status) => status.server)).not.toContain("project"); + } finally { + session.dispose(); + } + } + renameSync(cwd, old); + renameSync(replacement, cwd); + const { session } = await sdk({ + cwd, + agentDir: cwd, + settingsManager, + authStorage: AuthStorage.inMemory(), + projectMcpAdmission: admission, + }); + try { + expect(manager(session).getDeclarationSnapshot()!.declarations).toEqual( + expect.objectContaining({ user: expect.any(Object) }), + ); + expect(manager(session).getDeclarationSnapshot()!.declarations).not.toHaveProperty("project"); + } finally { + session.dispose(); + } + }); + + it("leaves every explicitly supplied MCP manager untouched across the project-admission matrix", async () => { + const physicalRoot = mkdtempSync(join(tmpdir(), "sdk-mcp-explicit-C04-target-")); + const root = `${physicalRoot}-C04-root`; + const agentDir = join(root, "agent"); + const replacement = `${physicalRoot}-replacement`; + const old = `${physicalRoot}-old`; + cleanup.push(root, old, replacement, physicalRoot); + // C04 deliberately has one symlink: the root spelling. The SDK receives the + // physical spelling below, so this test never accidentally treats an alias as + // a valid admission. + symlinkSync(physicalRoot, root, "dir"); + mkdirSync(agentDir, { recursive: true }); + const authority = createMcpProjectTrustAuthority({ + revision: "r1", + allowedProjectDirectories: [physicalRoot], + }); + const validAdmission = admitProjectMcpDeclarations(physicalRoot, authority)!; + + const cases: Array<{ name: string; admission: unknown; replaceRoot?: boolean }> = [ + { name: "no admission", admission: undefined }, + { name: "a genuine valid admission", admission: validAdmission }, + { name: "a forged admission", admission: Object.freeze(Object.create(null)) }, + { name: "a stale admission after root identity replacement", admission: validAdmission, replaceRoot: true }, + ]; + + for (const testCase of cases) { + const storageCalls: SettingsScope[] = []; + const settingsManager = SettingsManager.fromStorage({ + withLock(scope, callback) { + storageCalls.push(scope); + const current = + scope === "global" + ? JSON.stringify({ mcpServers: server("ordinary-global", "https://global.example/mcp") }) + : JSON.stringify({ + mcpDeclarations: { + version: 1, + servers: { + project: { name: "project", url: "https://project.example/mcp", enabled: true }, + }, + }, + }); + return callback(current); + }, + }); + // This is intentionally an ordinary SettingsManager project-scope load. + // The boundary below is only about *scoped project-MCP* composition/read/snapshot. + expect(storageCalls).toContain("project"); + if (testCase.replaceRoot) { + mkdirSync(replacement); + renameSync(physicalRoot, old); + renameSync(replacement, physicalRoot); + } + + const supplied = new McpManager({ + authStorage: AuthStorage.inMemory(), + getUserServers: () => server("caller", "https://caller.example/mcp"), + }); + const { session } = await sdk({ + cwd: root, + agentDir, + authStorage: AuthStorage.inMemory(), + settingsManager, + projectMcpAdmission: testCase.admission as never, + mcpManager: supplied, + }); + try { + expect(manager(session), testCase.name).toBe(supplied); + expect( + supplied.listStatus().map((status) => status.server), + testCase.name, + ).toContain("caller"); + expect( + await invokeHostRequest(supplied.hostHandlers()["mcp.config"] as never, { server: "caller" }), + testCase.name, + ).toEqual({ + url: "https://caller.example/mcp", + }); + expect(supplied.getDeclarationSnapshot(), testCase.name).toBeUndefined(); + // Explicit-manager authority means zero scoped project-MCP composition, + // reader construction/read, and runtime declaration snapshot work. + expect(mcpBoundarySpies.composeProjectReader, testCase.name).not.toHaveBeenCalled(); + expect(mcpBoundarySpies.createScopedReader, testCase.name).not.toHaveBeenCalled(); + expect(mcpBoundarySpies.readScopedDocument, testCase.name).not.toHaveBeenCalled(); + expect(mcpBoundarySpies.createRuntimeSnapshot, testCase.name).not.toHaveBeenCalled(); + expect(mcpBoundarySpies.ensureKernelPython, testCase.name).not.toHaveBeenCalled(); + expect(mcpBoundarySpies.spawnSync, testCase.name).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + mcpBoundarySpies.composeProjectReader.mockClear(); + mcpBoundarySpies.createScopedReader.mockClear(); + mcpBoundarySpies.readScopedDocument.mockClear(); + mcpBoundarySpies.createRuntimeSnapshot.mockClear(); + mcpBoundarySpies.ensureKernelPython.mockClear(); + mcpBoundarySpies.spawnSync.mockClear(); + } + }); +}); From 9ca2c83a28771931328fe29a931f4fa86e225739 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 05:55:03 -0700 Subject: [PATCH 5/8] fix(mcp): make cleanup failures observable --- .../src/core/mcp/mcp-declaration-command.ts | 25 ++++++++++--------- .../coding-agent/src/core/mcp/mcp-probe.ts | 24 ++++++++++-------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts b/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts index 4efdb6abe..b18b60c37 100644 --- a/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts +++ b/packages/coding-agent/src/core/mcp/mcp-declaration-command.ts @@ -1,21 +1,14 @@ import type { SettingsManager } from "../settings-manager.js"; import { addMcpDeclaration, + type McpDeclarationScope, parseMcpDeclarationDocument, previewMcpProbe, removeMcpDeclaration, - type McpDeclarationScope, } from "./mcp-declarations.js"; +import { type McpDeclarationProbeOptions, type McpProbeTransport, runMcpDeclarationProbe } from "./mcp-probe.js"; +import { type ProjectMcpDeclarationAdmission, requireProjectMcpDeclarationAdmission } from "./mcp-project-trust.js"; import { redactMcpDeclaration, redactMcpDeclarationDocument } from "./mcp-redaction.js"; -import { - runMcpDeclarationProbe, - type McpDeclarationProbeOptions, - type McpProbeTransport, -} from "./mcp-probe.js"; -import { - requireProjectMcpDeclarationAdmission, - type ProjectMcpDeclarationAdmission, -} from "./mcp-project-trust.js"; export type McpDeclarationCommand = | { kind: "list"; scope: McpDeclarationScope } @@ -30,7 +23,10 @@ function usage(): never { } function parseScope(words: string[]): { words: string[]; scope: McpDeclarationScope } { - const projectIndexes = words.reduce((indexes, word, index) => (word === "--project" ? [...indexes, index] : indexes), []); + const projectIndexes: number[] = []; + for (const [index, word] of words.entries()) { + if (word === "--project") projectIndexes.push(index); + } if (projectIndexes.length > 1 || (projectIndexes.length === 1 && projectIndexes[0] !== words.length - 1)) usage(); return { words: words.filter((word) => word !== "--project"), scope: projectIndexes.length ? "project" : "user" }; } @@ -41,7 +37,12 @@ export function parseMcpDeclarationCommand(args: string[]): McpDeclarationComman const [kind, ...operands] = words; if (kind === "list" && operands.length === 0) return { kind, scope }; if ( - (kind === "inspect" || kind === "preview" || kind === "test" || kind === "enable" || kind === "disable" || kind === "remove") && + (kind === "inspect" || + kind === "preview" || + kind === "test" || + kind === "enable" || + kind === "disable" || + kind === "remove") && operands.length === 1 ) { return { kind, scope, name: operands[0]! }; diff --git a/packages/coding-agent/src/core/mcp/mcp-probe.ts b/packages/coding-agent/src/core/mcp/mcp-probe.ts index 8823b2baa..429468b09 100644 --- a/packages/coding-agent/src/core/mcp/mcp-probe.ts +++ b/packages/coding-agent/src/core/mcp/mcp-probe.ts @@ -59,7 +59,9 @@ function withDeadline(promise: Promise | T, signal: AbortSignal): Promise< const abort = () => reject(publicProbeError("timeout")); if (signal.aborted) abort(); else signal.addEventListener("abort", abort, { once: true }); - Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + Promise.resolve(promise) + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", abort)); }); } @@ -82,9 +84,12 @@ export async function runMcpDeclarationProbe( const controller = new AbortController(); const deadlineTimer = setTimeout(() => controller.abort(), timeoutMs); let session: McpProbeSession | undefined; - let failure = false; + let failure: Error | undefined; try { - session = await withDeadline(transport.open({ url: declaration.url, signal: controller.signal }), controller.signal); + session = await withDeadline( + transport.open({ url: declaration.url, signal: controller.signal }), + controller.signal, + ); await withDeadline( session.request({ method: "initialize", @@ -98,13 +103,9 @@ export async function runMcpDeclarationProbe( controller.signal, ); await withDeadline(session.request({ method: "tools/list", signal: controller.signal }), controller.signal); - return { initialized: true, toolsListed: true }; } catch (error) { - failure = true; controller.abort(); - throw error instanceof Error && error.message === "MCP probe timed out." - ? error - : publicProbeError("failed"); + failure = error instanceof Error && error.message === "MCP probe timed out." ? error : publicProbeError("failed"); } finally { if (session) { try { @@ -115,11 +116,12 @@ export async function runMcpDeclarationProbe( // A close failure must never disclose transport data. Preserve an // earlier request failure, but do not report a false success when // cleanup itself failed or exceeded the total deadline. - if (!failure) { - throw controller.signal.aborted ? publicProbeError("timeout") : publicProbeError("failed"); - } + if (!failure) + failure = controller.signal.aborted ? publicProbeError("timeout") : publicProbeError("failed"); } } clearTimeout(deadlineTimer); } + if (failure) throw failure; + return { initialized: true, toolsListed: true }; } From 843669340b1371ca76f2b4351896771200e82c82 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 06:26:26 -0700 Subject: [PATCH 6/8] fix(lifecycle): harden daemon and kernel teardown --- .../coding-agent/src/core/agent-session.ts | 8 +- .../coding-agent/src/core/kernel/index.ts | 12 ++- .../src/modes/daemon/daemon-supervisor.ts | 15 +++- .../test/agent-session-concurrent.test.ts | 27 ++++++ .../test/agent-session-recursion.test.ts | 60 +++++++++++++ .../test/daemon-supervisor-monitor.test.ts | 87 +++++++++++++++++++ .../4685-daemon-client-modes.test.ts | 16 ++-- 7 files changed, 212 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8d5f15b0a..c55a8c22c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3791,10 +3791,14 @@ export class AgentSession { // refinement work. The provisioner forwards this to KernelManager, which // aborts each request and awaits its handler before its connection closes. // This prevents an old session's host handler from surviving replacement. - await this._ipythonKernelProvisioner?.dispose(); + // Capture the refinement drain before the first await: this preserves a + // final agent_end's serialized work while kernel disposal is pending. + const drain = this._drainPendingRefinementForDisposal(); + const kernelDispose = this._ipythonKernelProvisioner?.dispose(); + if (kernelDispose) await kernelDispose; // Drain before marking _disposing so a refine triggered at the final // agent_end completes instead of being aborted by dispose(). - await this._drainPendingRefinementForDisposal(); + await drain; if (this._disposed) { return this._disposeCallbacksPromise; } diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index 39e62794b..8ed82cd1f 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -651,6 +651,8 @@ export class KernelManager { private readonly handledHostRequestCommIds = new Set(); /** Monotonically revokes all host-request authority across kernel lifecycles. */ private hostRequestGeneration = 0; + /** Closed synchronously before teardown can await a final snapshot. */ + private hostRequestAdmissionOpen = true; private readonly activeHostRequests = new Map(); private readonly hostRequestIdsByComm = new Map(); private kernel?: ChildProcess; @@ -845,6 +847,7 @@ export class KernelManager { } this.state = "running"; + this.hostRequestAdmissionOpen = true; this.startForkedLivenessMonitor(); } @@ -1356,6 +1359,11 @@ export class KernelManager { } private startHostRequestFromComm(commId: string, data: unknown): void { + // A snapshot keeps the kernel running long enough for an internal execute; + // teardown nonetheless must not admit newly injected comm work in that gap. + if (!this.hostRequestAdmissionOpen) { + return; + } if (this.handledHostRequestCommIds.has(commId)) { return; } @@ -1429,6 +1437,7 @@ export class KernelManager { /** Abort every active request before a kernel connection is replaced or closed. */ private revokeHostRequests(): void { + this.hostRequestAdmissionOpen = false; this.hostRequestGeneration += 1; for (const request of this.activeHostRequests.values()) { request.controller.abort(); @@ -1610,11 +1619,10 @@ export class KernelManager { } async kill(): Promise { + // Force kill must not wait on a handler that ignores its abort signal. this.revokeHostRequests(); - const inFlightHostRequests = [...this.inFlightHostRequests]; this.state = "shutdown"; liveKernels.delete(this); - await this.waitForHostRequestsToSettle(inFlightHostRequests); this.cleanupResources("SIGKILL"); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 967bb0fec..3aa160c93 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -953,10 +953,12 @@ export class DaemonSupervisor { stopRevision: 0, launchEnv: descriptor.launchEnv, }; - this.workers.set(descriptor.workerId, worker); + // Sanitization is a durable admission boundary. Do not expose a worker to + // recovery/adoption until its descriptor no longer contains caller secrets. if (JSON.stringify(storedLaunchEnv) !== JSON.stringify(descriptor.launchEnv)) { this.persistWorker(worker); } + this.workers.set(descriptor.workerId, worker); } catch (error) { this.log(`Ignoring invalid worker descriptor ${path}: ${String(error)}`); } @@ -2156,11 +2158,18 @@ export class DaemonSupervisor { throw new Error("Session is not owned by this client"); } const previousDescriptor = worker.descriptor; - worker.descriptor = { ...previousDescriptor, ownerClientId: undefined, launchEnv: undefined }; + const previousLaunchEnv = worker.launchEnv; + const persistedLaunchEnv = filterPersistedDaemonLaunchEnv(previousLaunchEnv); + worker.descriptor = { + ...previousDescriptor, + ownerClientId: undefined, + ...(persistedLaunchEnv ? { launchEnv: persistedLaunchEnv } : { launchEnv: undefined }), + }; try { this.persistWorker(worker); } catch (error) { worker.descriptor = previousDescriptor; + worker.launchEnv = previousLaunchEnv; throw error; } worker.promotedOwnerClientId = clientId; @@ -2168,7 +2177,7 @@ export class DaemonSupervisor { clearTimeout(worker.ownerCleanupTimer); worker.ownerCleanupTimer = undefined; } - worker.launchEnv = undefined; + worker.launchEnv = persistedLaunchEnv; await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`)); } diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 3321b7c35..47225276a 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -192,6 +192,33 @@ describe("AgentSession concurrent prompt guard", () => { expect(disposed).toBe(true); }); + it("starts the refinement drain before awaiting a deferred kernel provisioner dispose", async () => { + createSession(); + const order: string[] = []; + let releaseKernelDispose: () => void = () => {}; + const kernelDisposeGate = new Promise((resolve) => { + releaseKernelDispose = resolve; + }); + const internals = session as unknown as { + _drainPendingRefinementForDisposal: () => Promise; + _ipythonKernelProvisioner?: { dispose(): Promise }; + }; + vi.spyOn(internals, "_drainPendingRefinementForDisposal").mockImplementation(async () => { + order.push("drain"); + }); + internals._ipythonKernelProvisioner = { + dispose: vi.fn(async () => { + order.push("kernel-dispose"); + await kernelDisposeGate; + }), + }; + + const disposal = session.disposeAsync(); + expect(order).toEqual(["drain", "kernel-dispose"]); + releaseKernelDispose(); + await disposal; + }); + it("should throw when prompt() called while streaming", async () => { createSession(); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index a0c33681b..8003bba40 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -3097,6 +3097,66 @@ print(_result.name) } }); + it("force kills without awaiting an abort-ignoring host handler", async () => { + let started = false; + let releaseHandler: () => void = () => {}; + const handlerGate = new Promise((resolve) => { + releaseHandler = resolve; + }); + const manager = new KernelManager({ + python: process.execPath, + hostHandlers: { + "rlm.run": createRlmRunHostHandler(async () => { + started = true; + await handlerGate; + return { + answer: "unused", + usage: { prompt_tokens: 1, completion_tokens: 1 }, + turns: 1, + session_dir: null, + model: "test/model", + }; + }), + }, + }); + const kernel = manager as unknown as KernelCommTestApi & { + kernel?: { kill(signal: NodeJS.Signals): void }; + }; + const kill = vi.fn(); + kernel.kernel = { kill }; + try { + kernel.handleCommMessage(rlmCommOpen("comm-force-kill", "slow child")); + expect(started).toBe(true); + await expectSettlesWithin(manager.kill(), 100); + expect(kill).toHaveBeenCalledWith("SIGKILL"); + } finally { + releaseHandler(); + } + }); + + it("does not admit a host comm injected while dispose awaits its final snapshot", async () => { + const handler = vi.fn(async () => ({ + answer: "unused", + usage: { prompt_tokens: 1, completion_tokens: 1 }, + turns: 1, + session_dir: null, + model: "test/model", + })); + const manager = new KernelManager({ + python: process.execPath, + hostHandlers: { "rlm.run": createRlmRunHostHandler(handler) }, + }); + const kernel = manager as unknown as KernelCommTestApi & { + flushSnapshotForDispose(): Promise; + }; + kernel.flushSnapshotForDispose = async () => { + kernel.handleCommMessage(rlmCommOpen("comm-injected-during-snapshot", "denied")); + }; + + await manager.dispose(); + expect(handler).not.toHaveBeenCalled(); + }); + it("rejects removed background rlm comm request types", async () => { const replies: CapturedCommReply[] = []; const manager = new KernelManager({ diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index c9a9ba95f..7e3eec87d 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -2877,6 +2877,93 @@ describe("daemon worker supervisor monitoring", () => { expect(stopWorker).not.toHaveBeenCalled(); }); + it("persists a sanitized descriptor before adopting it for recovery", () => { + const descriptorDir = mkdtempSync(join(tmpdir(), "prime-supervisor-descriptor-sanitize-")); + const descriptorPath = join(descriptorDir, "worker-1.json"); + const descriptor = { + version: 1, + supervisorSocketPath: "/tmp/supervisor.sock", + workerId: "worker-1", + pid: process.pid, + socketPath: "/tmp/worker-1.sock", + authenticationToken: "token", + rootActiveSessionId: "active-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + consecutiveFailures: 0, + createCommand: { type: "create" }, + launchEnv: { TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", SECRET_TOKEN: "must-not-persist" }, + }; + try { + writeFileSync(descriptorPath, `${JSON.stringify(descriptor)}\n`); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + descriptorDir, + socketPath: "/tmp/supervisor.sock", + workers: new Map(), + log: vi.fn(), + persistWorker(worker: { descriptor: object }) { + writeFileSync(descriptorPath, `${JSON.stringify(worker.descriptor)}\n`); + }, + }) as { + workers: Map; + loadWorkerDescriptors(): void; + }; + + supervisor.loadWorkerDescriptors(); + + expect(supervisor.workers.get("worker-1")?.descriptor.launchEnv).toEqual({ + TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", + }); + expect(supervisor.workers.get("worker-1")?.launchEnv).toEqual({ TSX_TSCONFIG_PATH: "/tmp/tsconfig.json" }); + expect(JSON.parse(readFileSync(descriptorPath, "utf8"))).toMatchObject({ + launchEnv: { TSX_TSCONFIG_PATH: "/tmp/tsconfig.json" }, + }); + expect(readFileSync(descriptorPath, "utf8")).not.toContain("SECRET_TOKEN"); + } finally { + rmSync(descriptorDir, { recursive: true, force: true }); + } + }); + + it("does not adopt a descriptor when sanitization persistence fails", () => { + const descriptorDir = mkdtempSync(join(tmpdir(), "prime-supervisor-descriptor-sanitize-fail-")); + const descriptorPath = join(descriptorDir, "worker-1.json"); + const original = { + version: 1, + supervisorSocketPath: "/tmp/supervisor.sock", + workerId: "worker-1", + pid: process.pid, + socketPath: "/tmp/worker-1.sock", + authenticationToken: "token", + rootActiveSessionId: "active-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + consecutiveFailures: 0, + createCommand: { type: "create" }, + launchEnv: { TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", SECRET_TOKEN: "secret" }, + }; + try { + writeFileSync(descriptorPath, `${JSON.stringify(original)}\n`); + const log = vi.fn(); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + descriptorDir, + socketPath: "/tmp/supervisor.sock", + workers: new Map(), + log, + persistWorker: () => { + throw new Error("disk full"); + }, + }) as { workers: Map; loadWorkerDescriptors(): void }; + + supervisor.loadWorkerDescriptors(); + + expect(supervisor.workers.size).toBe(0); + expect(readFileSync(descriptorPath, "utf8")).toBe(`${JSON.stringify(original)}\n`); + expect(log).toHaveBeenCalledWith(expect.stringContaining("disk full")); + } finally { + rmSync(descriptorDir, { recursive: true, force: true }); + } + }); + it("ignores malformed persisted worker descriptors", () => { const descriptorDir = mkdtempSync(join(tmpdir(), "prime-supervisor-descriptor-test-")); try { diff --git a/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts b/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts index a0da85d25..3b8163f28 100644 --- a/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts +++ b/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts @@ -151,9 +151,12 @@ async function runRpc( describe("ENG-4685 daemon-backed client modes", () => { it("commits owned-worker promotion before best-effort peer synchronization", async () => { const client = { id: "client-1" } as DaemonSocketClient; - const worker = { + const worker: { + descriptor: { ownerClientId: string | undefined; launchEnv?: Record }; + launchEnv?: Record; + } = { descriptor: { ownerClientId: "protocol-client" }, - launchEnv: { TEST: "value" }, + launchEnv: { TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", SECRET_TOKEN: "must-not-persist" }, }; const persistWorker = vi.fn(); const syncAgentPeers = vi.fn(async () => { @@ -173,7 +176,8 @@ describe("ENG-4685 daemon-backed client modes", () => { await supervisor.promoteOwnedWorker(client, worker); expect(worker.descriptor.ownerClientId).toBeUndefined(); - expect(worker.launchEnv).toBeUndefined(); + expect(worker.descriptor.launchEnv).toEqual({ TSX_TSCONFIG_PATH: "/tmp/tsconfig.json" }); + expect(worker.launchEnv).toEqual({ TSX_TSCONFIG_PATH: "/tmp/tsconfig.json" }); expect(persistWorker).toHaveBeenCalledOnce(); expect(syncAgentPeers).toHaveBeenCalledOnce(); expect(log).toHaveBeenCalledWith(expect.stringContaining("peer unavailable")); @@ -181,11 +185,11 @@ describe("ENG-4685 daemon-backed client modes", () => { it("rolls back owned-worker promotion when persistence fails", async () => { const client = { id: "client-1" } as DaemonSocketClient; - const descriptor = { ownerClientId: "protocol-client" }; + const descriptor = { ownerClientId: "protocol-client", launchEnv: undefined }; const timer = setTimeout(() => {}, 60_000); const worker = { descriptor, - launchEnv: { TEST: "value" }, + launchEnv: { TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", SECRET_TOKEN: "must-not-persist" }, ownerCleanupTimer: timer, }; const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { @@ -200,7 +204,7 @@ describe("ENG-4685 daemon-backed client modes", () => { await expect(supervisor.promoteOwnedWorker(client, worker)).rejects.toThrow("disk full"); expect(worker.descriptor).toBe(descriptor); - expect(worker.launchEnv).toEqual({ TEST: "value" }); + expect(worker.launchEnv).toEqual({ TSX_TSCONFIG_PATH: "/tmp/tsconfig.json", SECRET_TOKEN: "must-not-persist" }); expect(worker.ownerCleanupTimer).toBe(timer); clearTimeout(timer); }); From e19240b14e5c8772bdfa091c2491f2a143f34f9b Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 06:47:03 -0700 Subject: [PATCH 7/8] fix: harden MCP declaration probe boundaries --- .../coding-agent/src/cli/command-registry.ts | 5 +- .../coding-agent/src/cli/public-command.ts | 3 + .../coding-agent/src/core/mcp/mcp-probe.ts | 53 ++++++++++------ .../src/core/mcp/project-settings-openat.ts | 15 ++++- .../test/mcp-declarations.test.ts | 46 +++++++++++++- packages/coding-agent/test/mcp-probe.test.ts | 62 ++++++++++++++++++- .../coding-agent/test/public-command.test.ts | 11 ++++ 7 files changed, 167 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/src/cli/command-registry.ts b/packages/coding-agent/src/cli/command-registry.ts index cbb62558a..e4a78f8af 100644 --- a/packages/coding-agent/src/cli/command-registry.ts +++ b/packages/coding-agent/src/cli/command-registry.ts @@ -152,9 +152,10 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ }, { path: ["mcp"], - usage: "mcp ... [--project]", + usage: "mcp ... [--project]", summary: "Manage declarative MCP endpoint records", - description: "Commands only read or write credential-free declarations. They never start an MCP runtime or authentication flow. A test probe requires an injected local transport.", + description: + "Commands only read or write credential-free declarations. They never start an MCP runtime or authentication flow.", }, ]; diff --git a/packages/coding-agent/src/cli/public-command.ts b/packages/coding-agent/src/cli/public-command.ts index deb9dcd26..db82d3786 100644 --- a/packages/coding-agent/src/cli/public-command.ts +++ b/packages/coding-agent/src/cli/public-command.ts @@ -171,6 +171,9 @@ export function composeMcpProjectDeclarationAdmission( } async function runMcpDeclarationCommand(args: string[]): Promise { + // Probing is an internal injected-executor capability only. Reject this + // public spelling before parsing or any settings read can occur. + if (args[0] === "test") throw new Error("MCP probe is unavailable in this command context."); const command = parseMcpDeclarationCommand(args); const workingDirectory = process.cwd(); if (command.scope === "project") { diff --git a/packages/coding-agent/src/core/mcp/mcp-probe.ts b/packages/coding-agent/src/core/mcp/mcp-probe.ts index 429468b09..92d4a42b7 100644 --- a/packages/coding-agent/src/core/mcp/mcp-probe.ts +++ b/packages/coding-agent/src/core/mcp/mcp-probe.ts @@ -1,3 +1,4 @@ +import { VERSION } from "../../config.js"; import type { McpDeclaration } from "./mcp-declarations.js"; /** The probe never constructs a network client. Callers must inject a local test transport. */ @@ -12,6 +13,7 @@ export interface McpProbeOpenRequest { export interface McpProbeSession { request(request: McpProbeRequest): Promise; + notification(notification: McpProbeNotification): Promise | void; close(): Promise | void; } @@ -21,12 +23,17 @@ export interface McpProbeRequest { signal: AbortSignal; } +export interface McpProbeNotification { + method: "notifications/initialized"; + signal: AbortSignal; +} + export interface McpDeclarationProbeOptions { /** Explicit offline mode blocks before the injected transport is opened. */ offline?: boolean; /** A project declaration must have passed the C05 trust boundary first. */ trusted?: boolean; - /** Total wall-clock budget for opening, both protocol requests, and close. */ + /** Total wall-clock budget for opening and protocol requests. */ timeoutMs?: number; } @@ -81,14 +88,14 @@ export async function runMcpDeclarationProbe( if (options.trusted !== true) throw publicProbeError("untrusted"); const timeoutMs = boundedTimeout(options.timeoutMs); - const controller = new AbortController(); - const deadlineTimer = setTimeout(() => controller.abort(), timeoutMs); + const operationController = new AbortController(); + const operationDeadline = setTimeout(() => operationController.abort(), timeoutMs); let session: McpProbeSession | undefined; let failure: Error | undefined; try { session = await withDeadline( - transport.open({ url: declaration.url, signal: controller.signal }), - controller.signal, + transport.open({ url: declaration.url, signal: operationController.signal }), + operationController.signal, ); await withDeadline( session.request({ @@ -96,31 +103,41 @@ export async function runMcpDeclarationProbe( params: { protocolVersion: "2025-03-26", capabilities: {}, - clientInfo: { name: "Prime Agent" }, + clientInfo: { name: "Prime Agent", version: VERSION }, }, - signal: controller.signal, + signal: operationController.signal, }), - controller.signal, + operationController.signal, + ); + await withDeadline( + session.notification({ method: "notifications/initialized", signal: operationController.signal }), + operationController.signal, + ); + await withDeadline( + session.request({ method: "tools/list", signal: operationController.signal }), + operationController.signal, ); - await withDeadline(session.request({ method: "tools/list", signal: controller.signal }), controller.signal); } catch (error) { - controller.abort(); + operationController.abort(); failure = error instanceof Error && error.message === "MCP probe timed out." ? error : publicProbeError("failed"); } finally { + clearTimeout(operationDeadline); if (session) { + // Cleanup deliberately gets a fresh controller. An operation timeout aborts + // its signal, but must not prevent a bounded attempt to release the session. + const cleanupController = new AbortController(); + const cleanupDeadline = setTimeout(() => cleanupController.abort(), timeoutMs); try { - // Invoke close even after cancellation. The injected session owns its - // local cleanup and cannot be left open by a failed handshake. - await withDeadline(session.close(), controller.signal); + await withDeadline(session.close(), cleanupController.signal); } catch { - // A close failure must never disclose transport data. Preserve an - // earlier request failure, but do not report a false success when - // cleanup itself failed or exceeded the total deadline. + // The primary operation error always wins. Both paths intentionally + // redact adapter details, including errors emitted during close. if (!failure) - failure = controller.signal.aborted ? publicProbeError("timeout") : publicProbeError("failed"); + failure = cleanupController.signal.aborted ? publicProbeError("timeout") : publicProbeError("failed"); + } finally { + clearTimeout(cleanupDeadline); } } - clearTimeout(deadlineTimer); } if (failure) throw failure; return { initialized: true, toolsListed: true }; diff --git a/packages/coding-agent/src/core/mcp/project-settings-openat.ts b/packages/coding-agent/src/core/mcp/project-settings-openat.ts index 6677edcfb..72d1dc32a 100644 --- a/packages/coding-agent/src/core/mcp/project-settings-openat.ts +++ b/packages/coding-agent/src/core/mcp/project-settings-openat.ts @@ -91,9 +91,12 @@ def main(): try: try: prime=directory(3,".prime",action=="write"); agent=directory(prime,"agent",action=="write") except FileNotFoundError: - if action=="read": print('{"mcpDeclarations":null}'); return + if action=="read": print('{"hasMcpDeclarations":false,"mcpDeclarations":null}'); return raise - if action=="read": print(json.dumps({"mcpDeclarations":read(agent).get("mcpDeclarations")},ensure_ascii=False,allow_nan=False,separators=(",", ":"))) + if action=="read": + doc=read(agent) + has="mcpDeclarations" in doc + print(json.dumps({"hasMcpDeclarations":has,"mcpDeclarations":doc.get("mcpDeclarations") if has else None},ensure_ascii=False,allow_nan=False,separators=(",", ":"))) elif action=="write" and "document" in request: write(agent,request["document"]); print("{}") else: reject() finally: @@ -178,11 +181,17 @@ export class ProjectSettingsOpenat { typeof response !== "object" || response === null || Array.isArray(response) || + !Object.hasOwn(response, "hasMcpDeclarations") || !Object.hasOwn(response, "mcpDeclarations") ) unavailable(); + const { hasMcpDeclarations, mcpDeclarations } = response as { + hasMcpDeclarations: unknown; + mcpDeclarations: unknown; + }; + if (typeof hasMcpDeclarations !== "boolean" || (!hasMcpDeclarations && mcpDeclarations !== null)) unavailable(); try { - return parseMcpDeclarationDocument((response as { mcpDeclarations: unknown }).mcpDeclarations); + return parseMcpDeclarationDocument(hasMcpDeclarations ? mcpDeclarations : undefined); } catch { unavailable(); } diff --git a/packages/coding-agent/test/mcp-declarations.test.ts b/packages/coding-agent/test/mcp-declarations.test.ts index eec339bc0..dc5f44c1a 100644 --- a/packages/coding-agent/test/mcp-declarations.test.ts +++ b/packages/coding-agent/test/mcp-declarations.test.ts @@ -11,6 +11,7 @@ import { } from "../src/core/mcp/mcp-declarations.js"; import { admitProjectMcpDeclarations, resolveProjectMcpDeclarations } from "../src/core/mcp/mcp-project-trust.js"; import { redactMcpValue } from "../src/core/mcp/mcp-redaction.js"; +import { ProjectSettingsOpenat } from "../src/core/mcp/project-settings-openat.js"; import { SettingsManager } from "../src/core/settings-manager.js"; const projectDocument = { @@ -89,12 +90,16 @@ describe("M01 declarative MCP contract", () => { executeMcpDeclarationCommand(command, settings, undefined, { probeTransport: { async open() { - return { request: async ({ method }) => void methods.push(method), close: () => undefined }; + return { + request: async ({ method }) => void methods.push(method), + notification: async ({ method }) => void methods.push(method), + close: () => undefined, + }; }, }, }), ).resolves.toEqual({ initialized: true, toolsListed: true }); - expect(methods).toEqual(["initialize", "tools/list"]); + expect(methods).toEqual(["initialize", "notifications/initialized", "tools/list"]); }); it("rejects a structurally forged authority before authorization or project reads", () => { @@ -213,6 +218,9 @@ describe("M01 declarative MCP contract", () => { async request({ method }) { calls.push(method); }, + async notification({ method }) { + calls.push(method); + }, close() {}, }; }, @@ -220,7 +228,7 @@ describe("M01 declarative MCP contract", () => { }, ), ).resolves.toEqual({ initialized: true, toolsListed: true }); - expect(calls).toEqual(["open", "initialize", "tools/list"]); + expect(calls).toEqual(["open", "initialize", "notifications/initialized", "tools/list"]); } finally { f.dispose(); } @@ -241,6 +249,38 @@ describe("M01 declarative MCP contract", () => { }); expect(JSON.stringify(redacted)).not.toContain("secret"); }); + it("treats missing project declaration directories and unrelated settings as an empty declaration document", async () => { + const f = fixture(); + try { + const admission = admitProjectMcpDeclarations(f.directory, f.authority)!; + const settings = await ProjectSettingsOpenat.create(admission); + expect(settings.getDocument()).toEqual(emptyMcpDeclarationDocument()); + mkdirSync(join(f.directory, ".prime", "agent"), { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync(join(f.directory, ".prime", "agent", "settings.json"), JSON.stringify({ ordinary: true })); + expect(settings.getDocument()).toEqual(emptyMcpDeclarationDocument()); + } finally { + f.dispose(); + } + }); + + it("rejects an explicit null declaration value", async () => { + const f = fixture(); + try { + const admission = admitProjectMcpDeclarations(f.directory, f.authority)!; + mkdirSync(join(f.directory, ".prime", "agent"), { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(f.directory, ".prime", "agent", "settings.json"), + JSON.stringify({ mcpDeclarations: null }), + ); + const settings = await ProjectSettingsOpenat.create(admission); + expect(() => settings.getDocument()).toThrow("Project MCP declarations are unavailable."); + } finally { + f.dispose(); + } + }); + it("fails closed when a queued project write loses its approved root", async () => { const f = fixture(); const replacement = `${f.directory}-replacement`; diff --git a/packages/coding-agent/test/mcp-probe.test.ts b/packages/coding-agent/test/mcp-probe.test.ts index 8e5f2e045..d2e04718c 100644 --- a/packages/coding-agent/test/mcp-probe.test.ts +++ b/packages/coding-agent/test/mcp-probe.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { VERSION } from "../src/config.js"; import { type McpProbeSession, type McpProbeTransport, runMcpDeclarationProbe } from "../src/core/mcp/mcp-probe.js"; const declaration = { name: "catalog", url: "https://catalog.test/mcp", enabled: true }; @@ -11,6 +12,9 @@ function fakeTransport(calls: string[], overrides: Partial = {} async request(request) { calls.push(request.method); }, + async notification(notification) { + calls.push(notification.method); + }, async close() { calls.push("close"); }, @@ -21,16 +25,36 @@ function fakeTransport(calls: string[], overrides: Partial = {} } describe("M01 injected MCP probe", () => { - it("uses only initialize then tools/list and always closes the injected session", async () => { + it("initializes, notifies, then lists tools and always closes the injected session", async () => { const calls: string[] = []; await expect(runMcpDeclarationProbe(declaration, fakeTransport(calls), { trusted: true })).resolves.toEqual({ initialized: true, toolsListed: true, }); - expect(calls).toEqual(["open:https://catalog.test/mcp", "initialize", "tools/list", "close"]); + expect(calls).toEqual([ + "open:https://catalog.test/mcp", + "initialize", + "notifications/initialized", + "tools/list", + "close", + ]); expect(calls.join(" ")).not.toContain("tools/call"); }); + it("sends the exact shipped version in initialize clientInfo", async () => { + let initialize: unknown; + const transport = fakeTransport([], { + async request(request) { + if (request.method === "initialize") initialize = request; + }, + }); + await runMcpDeclarationProbe(declaration, transport, { trusted: true }); + expect(initialize).toMatchObject({ + method: "initialize", + params: { clientInfo: { name: "Prime Agent", version: VERSION } }, + }); + }); + it.each([ ["disabled", { ...declaration, enabled: false }, { trusted: true }, "disabled"], ["offline", declaration, { trusted: true, offline: true }, "offline"], @@ -55,6 +79,40 @@ describe("M01 injected MCP probe", () => { expect(calls).toEqual(["open:https://catalog.test/mcp", "initialize", "close"]); }); + it("preserves a redacted primary operation failure when close also fails", async () => { + const transport = fakeTransport([], { + async request() { + throw new Error("operation-secret"); + }, + async close() { + throw new Error("close-secret"); + }, + }); + await expect(runMcpDeclarationProbe(declaration, transport, { trusted: true })).rejects.toThrow( + "MCP probe failed.", + ); + }); + + it("uses an independent bounded cleanup controller after the operation aborts", async () => { + let closeCalled = false; + const transport: McpProbeTransport = { + async open() { + return { + request: async () => new Promise(() => undefined), + notification: async () => undefined, + close: async () => { + closeCalled = true; + return new Promise(() => undefined); + }, + }; + }, + }; + await expect(runMcpDeclarationProbe(declaration, transport, { trusted: true, timeoutMs: 10 })).rejects.toThrow( + "MCP probe timed out.", + ); + expect(closeCalled).toBe(true); + }); + it("aborts a hanging injected transport within its bounded timeout", async () => { let aborted = false; const transport: McpProbeTransport = { diff --git a/packages/coding-agent/test/public-command.test.ts b/packages/coding-agent/test/public-command.test.ts index b24b115df..d8fffe4ff 100644 --- a/packages/coding-agent/test/public-command.test.ts +++ b/packages/coding-agent/test/public-command.test.ts @@ -58,6 +58,17 @@ describe("public command routing", () => { vi.restoreAllMocks(); }); + it("rejects public mcp test before command parsing can access settings", async () => { + await expect(handlePublicCommand(["mcp", "test", "catalog"])).resolves.toMatchObject({ handled: true }); + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("MCP probe is unavailable")); + }); + + it("does not advertise mcp test in public help", () => { + expect(formatTopLevelHelp()).toContain("mcp"); + expect(formatTopLevelHelp()).not.toContain("mcp { await expect(handlePublicCommand(["attach", "worker"])).resolves.toEqual({ handled: false, From 41ef592f8474a1d67b25ee6e495df24be3ab804e Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 11 Aug 2026 08:13:14 -0700 Subject: [PATCH 8/8] fix(daemon): preserve supervisor registry for recovered workers --- .../daemon/daemon-supervisor-ownership.ts | 18 +++--- .../src/modes/daemon/daemon-supervisor.ts | 9 ++- .../test/daemon-supervisor-monitor.test.ts | 61 ++++++++++++++----- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts index 7fbcb7620..af5491f18 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts @@ -14,7 +14,7 @@ import lockfile from "proper-lockfile"; import { getProcessStartId } from "../../core/session-lease.js"; import { defaultDaemonSocketDir } from "./daemon-socket.js"; -const DAEMON_SUPERVISOR_REGISTRY_DIR_ENV = "PRIME_AGENT_INTERNAL_DAEMON_SUPERVISOR_REGISTRY_DIR"; +export const DAEMON_SUPERVISOR_REGISTRY_DIR_ENV = "PRIME_AGENT_INTERNAL_DAEMON_SUPERVISOR_REGISTRY_DIR"; const OWNER_VERSION = 1; const REGISTRY_LOCK_STALE_MS = 5000; @@ -246,7 +246,7 @@ class DaemonShutdownAdmission { } } -function defaultDaemonSupervisorRegistryDir(environment: NodeJS.ProcessEnv = process.env): string { +export function getDaemonSupervisorRegistryDir(environment: NodeJS.ProcessEnv = process.env): string { return environment[DAEMON_SUPERVISOR_REGISTRY_DIR_ENV] ?? resolve(defaultDaemonSocketDir(), "supervisor-owners"); } @@ -275,7 +275,7 @@ async function mutateDaemonSupervisorOwner( generation: string, expectedToken: string, mutation: (owner: DaemonSupervisorOwnerRecord) => void, - registryDir: string = defaultDaemonSupervisorRegistryDir(), + registryDir: string = getDaemonSupervisorRegistryDir(), ): Promise { return withDaemonSupervisorRegistryGuard(registryDir, () => { const directory = ownerDirectoryPath(registryDir, generation); @@ -303,7 +303,7 @@ async function mutateDaemonSupervisorOwner( export async function acquireDaemonSupervisorOwnership( options: AcquireDaemonSupervisorOwnershipOptions, ): Promise { - const registryDir = options.registryDir ?? defaultDaemonSupervisorRegistryDir(); + const registryDir = options.registryDir ?? getDaemonSupervisorRegistryDir(); mkdirSync(registryDir, { recursive: true, mode: 0o700 }); const token = randomUUID(); const processStartId = getProcessStartId(process.pid); @@ -371,7 +371,7 @@ export async function assertDaemonSupervisorOwnerCurrent( }, validatedFingerprint?: string, ): Promise { - const registryDir = defaultDaemonSupervisorRegistryDir(); + const registryDir = getDaemonSupervisorRegistryDir(); const current = readOwnerRecord(ownerDirectoryPath(registryDir, owner.generation)); if ( !current || @@ -390,7 +390,7 @@ export async function assertDaemonSupervisorOwnerCurrent( } export async function acquireDaemonShutdownAdmission(): Promise { - const registryDir = defaultDaemonSupervisorRegistryDir(); + const registryDir = getDaemonSupervisorRegistryDir(); const processStartId = getProcessStartId(process.pid); while (true) { let acquired: DaemonShutdownAdmissionRecord | undefined; @@ -418,14 +418,14 @@ export async function acquireDaemonShutdownAdmission(): Promise { - const registryDir = defaultDaemonSupervisorRegistryDir(); + const registryDir = getDaemonSupervisorRegistryDir(); return withDaemonSupervisorRegistryGuard(registryDir, () => readActiveShutdownAdmission(registryDir) !== undefined); } export async function persistDaemonStartupFenceFromOwner( socketPath: string, hello: DaemonSupervisorHelloIdentity, - registryDir: string = defaultDaemonSupervisorRegistryDir(), + registryDir: string = getDaemonSupervisorRegistryDir(), ): Promise { mkdirSync(registryDir, { recursive: true, mode: 0o700 }); const fenceDirectory = resolve(registryDir, "startup-fences"); @@ -482,7 +482,7 @@ export async function persistDaemonStartupFenceFromOwner( export async function waitForDaemonStartupFence( socketPath: string, timeoutMs = 10_000, - registryDir: string = defaultDaemonSupervisorRegistryDir(), + registryDir: string = getDaemonSupervisorRegistryDir(), ): Promise { const path = startupFencePath(resolve(registryDir, "startup-fences"), socketPath); const deadline = Date.now() + timeoutMs; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3aa160c93..084218105 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -100,6 +100,8 @@ import { } from "./daemon-socket.js"; import { acquireDaemonSupervisorOwnership, + DAEMON_SUPERVISOR_REGISTRY_DIR_ENV, + getDaemonSupervisorRegistryDir, isDaemonShutdownAdmissionActive, waitForDaemonStartupFence, } from "./daemon-supervisor-ownership.js"; @@ -610,6 +612,8 @@ export class DaemonSupervisor { private readonly promptAdmissions = new Map>(); private readonly signalCleanupHandlers: Array<() => void> = []; private readonly descriptorDir: string; + /** Captured from host authority and forwarded to every worker launch. */ + private readonly supervisorRegistryDir = getDaemonSupervisorRegistryDir(); private readonly generation = randomUUID(); private readonly supervisorConfigPath: string; private readonly defaultSessionConfig: AgentSessionRuntimeConfig; @@ -653,13 +657,14 @@ export class DaemonSupervisor { throw new Error("Daemon supervisor config is missing agentDir"); } this.socketLease = await acquireDaemonSocketPathLease(this.socketPath); - await waitForDaemonStartupFence(this.socketPath); + await waitForDaemonStartupFence(this.socketPath, 10_000, this.supervisorRegistryDir); this.ownership = await acquireDaemonSupervisorOwnership({ socketPath: this.socketPath, descriptorDir: this.descriptorDir, agentDir, generation: this.generation, appVersion: VERSION, + registryDir: this.supervisorRegistryDir, }); await prepareDaemonSocketPath(this.socketPath, this.socketLease); @@ -2241,6 +2246,8 @@ export class DaemonSupervisor { [DAEMON_WORKER_ACTIVE_SESSION_ID_ENV]: rootActiveSessionId, [DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]: this.socketPath, [DAEMON_WORKER_RECOVERY_JOURNAL_ENV]: recoveryJournalPath, + // Host authority, set after inherited and caller launch env so neither can override it. + [DAEMON_SUPERVISOR_REGISTRY_DIR_ENV]: this.supervisorRegistryDir, [DAEMON_WORKER_STARTUP_GATE_FD_ENV]: String(WORKER_STARTUP_GATE_FD), [ORPHAN_PROCESS_JOURNAL_ENV]: orphanProcessJournalPath, [SESSION_LEASES_ENABLED_ENV]: "1", diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 7e3eec87d..a6a844212 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -20,6 +20,7 @@ import { } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { DAEMON_SUPERVISOR_REGISTRY_DIR_ENV } from "../src/modes/daemon/daemon-supervisor-ownership.js"; import { DAEMON_WORKER_STARTUP_GATE_COMMIT, DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, @@ -38,7 +39,7 @@ const workerLaunchTestState = vi.hoisted(() => ({ gateMarkerPath: "", tsxCliPath: "", cliEntrypoint: "", - spawned: [] as Array<{ child: ChildProcess; args: readonly string[] }>, + spawned: [] as Array<{ child: ChildProcess; args: readonly string[]; env: NodeJS.ProcessEnv | undefined }>, })); vi.mock("node:child_process", async (importOriginal) => { @@ -50,7 +51,7 @@ vi.mock("node:child_process", async (importOriginal) => { spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess { const child = actual.spawn(command, args, options); if (workerLaunchTestState.capture) { - workerLaunchTestState.spawned.push({ child, args }); + workerLaunchTestState.spawned.push({ child, args, env: options.env }); } return child; }, @@ -114,7 +115,7 @@ vi.mock("../src/core/session-lease.js", async (importOriginal) => { }; }); -const supervisorRegistryDirEnv = "PRIME_AGENT_INTERNAL_DAEMON_SUPERVISOR_REGISTRY_DIR"; +const supervisorRegistryDirEnv = DAEMON_SUPERVISOR_REGISTRY_DIR_ENV; const previousSupervisorRegistryDir = process.env[supervisorRegistryDirEnv]; const supervisorRegistryDirs = new Set(); @@ -640,7 +641,9 @@ describe("daemon worker supervisor monitoring", () => { const root = mkdtempSync(join(tmpdir(), "prime-supervisor-committed-gate-test-")); const descriptorDir = join(root, "descriptors"); const markerPath = join(root, "startup-marker"); + const registryDir = join(root, "isolated-supervisor-registry"); mkdirSync(descriptorDir, { recursive: true }); + mkdirSync(registryDir, { recursive: true }); supervisorRegistryDirs.add(root); workerLaunchTestState.capture = true; workerLaunchTestState.forceMissingProcessStartId = true; @@ -665,6 +668,7 @@ describe("daemon worker supervisor monitoring", () => { ...createSupervisorSnapshotState(), defaultSessionConfig: { cwd: root, agentDir: root }, descriptorDir, + supervisorRegistryDir: registryDir, socketPath: join(root, "supervisor.sock"), workers, shuttingDown: false, @@ -675,16 +679,37 @@ describe("daemon worker supervisor monitoring", () => { syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { - launchWorker(command: { - type: "create"; - config: { cwd: string; agentDir: string }; - }): Promise<{ descriptor: { lifecycle: string } }>; + launchWorker( + command: { type: "create"; config: { cwd: string; agentDir: string }; launchEnv?: Record }, + existing?: { + descriptor: { + lifecycle: string; + createCommand: { type: "create"; config: { cwd: string; agentDir: string } }; + launchEnv?: Record; + }; + }, + ): Promise<{ + descriptor: { + lifecycle: string; + createCommand: { type: "create"; config: { cwd: string; agentDir: string } }; + launchEnv?: Record; + }; + }>; }; - const worker = await supervisor.launchWorker({ type: "create", config: { cwd: root, agentDir: root } }); + const worker = await supervisor.launchWorker({ + type: "create", + config: { cwd: root, agentDir: root }, + launchEnv: { [DAEMON_SUPERVISOR_REGISTRY_DIR_ENV]: join(root, "untrusted-registry") }, + }); + await supervisor.launchWorker(worker.descriptor.createCommand, worker); + expect( + workerLaunchTestState.spawned.slice(-2).map(({ env }) => env?.[DAEMON_SUPERVISOR_REGISTRY_DIR_ENV]), + ).toEqual([registryDir, registryDir]); expect(readFileSync(markerPath, "utf8")).toBe("start\n"); - expect(connectWorker).toHaveBeenCalledOnce(); + expect(worker.descriptor.launchEnv?.[DAEMON_SUPERVISOR_REGISTRY_DIR_ENV]).toBeUndefined(); + expect(connectWorker).toHaveBeenCalledTimes(2); expect(worker.descriptor.lifecycle).toBe("ready"); expect(workers.size).toBe(1); expect(readdirSync(descriptorDir).filter((name) => name.endsWith(".json"))).toHaveLength(1); @@ -1464,7 +1489,10 @@ describe("daemon worker supervisor monitoring", () => { lifecycle: "ready" as const, }, summaries: new Map([ - ["root-active", { id: "root-active", sessionId: "root-session", activeSessionId: "root-active" } as SessionSummary], + [ + "root-active", + { id: "root-active", sessionId: "root-session", activeSessionId: "root-active" } as SessionSummary, + ], ]), snapshotCache: new Map(), transcriptCaches: new Map(), @@ -1512,20 +1540,21 @@ describe("daemon worker supervisor monitoring", () => { }) as { workers: typeof workers; workerStopCounts: Map; - handleCommand(client: DaemonSocketClient, command: { type: "kill"; activeSessionId: string }): Promise; + handleCommand( + client: DaemonSocketClient, + command: { type: "kill"; activeSessionId: string }, + ): Promise; handleWorkerFrame(target: typeof worker, frame: PrivateFrame): void; }; - await expect(supervisor.handleCommand({} as DaemonSocketClient, { type: "kill", activeSessionId: "root-active" })).resolves.toEqual( - success(undefined, "kill"), - ); + await expect( + supervisor.handleCommand({} as DaemonSocketClient, { type: "kill", activeSessionId: "root-active" }), + ).resolves.toEqual(success(undefined, "kill")); expect(stopWorkerUntracked).toHaveBeenCalledWith(worker, true, false, true, false, undefined); expect(workers.has(worker.descriptor.workerId)).toBe(false); expect(deleteWorkerDescriptor).toHaveBeenCalledWith(worker); expect(supervisor.workerStopCounts.has(worker)).toBe(false); }); - - it("cancels an in-flight recovery after an intentional stop tombstone", async () => { vi.useFakeTimers(); type RecoveryWorker = {