diff --git a/.changeset/fn-8809-secrets-chat-approval.md b/.changeset/fn-8809-secrets-chat-approval.md new file mode 100644 index 0000000000..6927707024 --- /dev/null +++ b/.changeset/fn-8809-secrets-chat-approval.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Repair chat-agent secret approvals and show actionable decision failures. +category: fix +dev: Prompt-gated secret reads now retain the registered engine session principal. diff --git a/packages/cli/src/__tests__/extension-permission-gates.test.ts b/packages/cli/src/__tests__/extension-permission-gates.test.ts index 84f1ecd7cc..d61b18e4f8 100644 --- a/packages/cli/src/__tests__/extension-permission-gates.test.ts +++ b/packages/cli/src/__tests__/extension-permission-gates.test.ts @@ -11,13 +11,15 @@ * friction-free on policy-gated tools (no approval row minted). * Expectations are HARDCODED — never derived from the constants under test. */ -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import express from "express"; import { join } from "node:path"; import { AgentStore, ApprovalRequestStore, SecretsStore, registerFusionSessionIdentity, + runWithFusionSessionIdentity, __clearFusionSessionIdentityRegistryForTests, type AgentPermissionPolicy, } from "@fusion/core"; @@ -29,6 +31,37 @@ import { pgDescribe, type MockApi, } from "./pg-extension-harness.js"; +import { registerApprovalRoutes } from "../../../dashboard/src/routes/register-approval-routes.js"; +import { request as requestRoute } from "../../../dashboard/src/test-request.js"; +import { ChatManager, __resetChatState, __setCreateResolvedAgentSession } from "../../../dashboard/src/chat.js"; + +const { createPiAgentSessionMock, piFindModelMock } = vi.hoisted(() => ({ + createPiAgentSessionMock: vi.fn(), + piFindModelMock: vi.fn((provider: string, id: string) => ({ provider, id })), +})); + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + LegacyCredentialStorage: { create: () => ({ setFallbackResolver: vi.fn(), getApiKey: vi.fn(), get: vi.fn(), set: vi.fn(), has: vi.fn(), hasAuth: vi.fn(), getAll: vi.fn(() => ({})), list: vi.fn(), logout: vi.fn(), remove: vi.fn(), reload: vi.fn() }) }, + createAgentSession: createPiAgentSessionMock, + createBashTool: vi.fn(() => ({ name: "bash" })), + createCodingTools: vi.fn(() => []), + createEditTool: () => ({ name: "edit" }), + createExtensionRuntime: vi.fn(), + createFindTool: () => ({ name: "find" }), + createGrepTool: () => ({ name: "grep" }), + createLsTool: () => ({ name: "ls" }), + createReadOnlyTools: vi.fn(() => []), + createReadTool: () => ({ name: "read" }), + createWriteTool: () => ({ name: "write" }), + DefaultResourceLoader: class { async reload() {} }, + DefaultPackageManager: class { async resolve() { return { extensions: [] }; } }, + discoverAndLoadExtensions: vi.fn(async () => ({ runtime: { pendingProviderRegistrations: [] }, errors: [] })), + getAgentDir: () => "/mock-agent-dir", + ModelRuntime: { create: async () => ({ getAuth: async () => ({ auth: { headers: {} } }), refresh: async () => {} }) }, + ModelRegistry: class { static create() { return new this(); } find(provider: string, id: string) { return piFindModelMock(provider, id); } getAll() { return []; } registerProvider() {} async refresh() {} async getApiKeyAndHeaders() { return { ok: true }; } }, + SessionManager: { inMemory: () => ({ getSessionId: () => undefined }) }, + SettingsManager: { create: () => ({}), inMemory: () => ({}) }, +})); const h = createPgExtensionHarness("fn-ext-perm-gates"); @@ -106,6 +139,43 @@ function freshApi(): MockApi { return api; } +/** + * Build only the production approval registrar around the same PostgreSQL-backed + * store used by the host extension. This keeps the reachability fixture in-process + * while exercising the real HTTP decision authorization and persistence path. + */ +function createApprovalDecisionApp() { + const app = express(); + const router = express.Router(); + app.use(express.json()); + registerApprovalRoutes({ + router, + store: h.store(), + runtimeLogger: { info() {}, warn() {}, error() {}, child() { return this; } } as any, + planningLogger: {} as any, + chatLogger: {} as any, + getProjectIdFromRequest: () => undefined, + getScopedStore: async () => h.store(), + getProjectContext: async () => ({ store: h.store(), engine: undefined, projectId: undefined }), + getProjectPluginLoader: async () => undefined, + prioritizeProjectsForCurrentDirectory: (projects: any[]) => projects, + emitRemoteRouteDiagnostic() {}, + emitAuthSyncAuditLog() {}, + parseScopeParam: () => undefined, + resolveAutomationStore: () => { throw new Error("not used by approval routes"); }, + resolveRoutineStore: () => { throw new Error("not used by approval routes"); }, + resolveRoutineRunner: () => { throw new Error("not used by approval routes"); }, + registerDispose() {}, + dispose() {}, + rethrowAsApiError(error: unknown): never { throw error; }, + } as any); + app.use(router); + app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(typeof error?.status === "number" ? error.status : 500).json({ error: error?.message ?? "Internal server error" }); + }); + return app; +} + pgDescribe("extension tool permission gates", () => { beforeAll(h.beforeAll); beforeEach(async () => { @@ -434,6 +504,191 @@ pgDescribe("extension tool permission gates", () => { expect(await approvals.list()).toHaveLength(1); }); + it("fn_secret_get: a registered durable chat agent is persisted when pi omits immediate agentId", async () => { + const cwd = h.rootDir(); + const tool = requireTool(freshApi(), "fn_secret_get"); + const secretsStore = injectSecretsStore(); + await secretsStore.createSecret({ scope: "project", key: "CHAT_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); + const dispose = registerFusionSessionIdentity(cwd, { agentId: "agent-1a009724", agentName: "Dashboard Chat Agent", purpose: "chat" }); + try { + const result = await tool.execute("chat-call", { key: "CHAT_TOKEN" }, undefined, undefined, { cwd }); + const request = await buildApprovalStore().get(result.details?.approvalRequestId as string); + expect(request?.requester).toMatchObject({ + actorId: "agent-1a009724", + actorType: "agent", + actorName: "Dashboard Chat Agent", + }); + } finally { + dispose(); + } + }); + + it("fn_secret_get: dashboard-chat pi invocation reaches real operator approve and deny routes", async () => { + const cwd = h.rootDir(); + const tool = requireTool(freshApi(), "fn_secret_get"); + const secretsStore = injectSecretsStore(); + const app = createApprovalDecisionApp(); + + /* + FNXC:SecretsAccessApproval 2026-08-05-22:33: + This is the production-reachability regression fixture for dashboard chat. + Chat supplies the durable agent to createResolvedAgentSession, pi wraps the + prompt with this invocation identity, and the real host extension receives + an ExtensionContext with no agentId. That chain must persist the named agent + so the server-derived operator can approve or deny rather than self-collide. + */ + const requestFor = async (key: string) => { + await secretsStore.createSecret({ scope: "project", key, plaintextValue: "not-in-approval", accessPolicy: "prompt" }); + const result = await runWithFusionSessionIdentity( + [cwd], + { agentId: "agent-1a009724", agentName: "Dashboard Chat Agent", purpose: "chat" }, + () => tool.execute(`chat-${key}`, { key }, undefined, undefined, { cwd }), + ); + const requestId = result.details?.approvalRequestId as string; + const approval = await buildApprovalStore().get(requestId); + expect(approval?.requester).toMatchObject({ + actorId: "agent-1a009724", + actorType: "agent", + actorName: "Dashboard Chat Agent", + }); + return requestId; + }; + + const approvedId = await requestFor("CHAT_APPROVE_TOKEN"); + const approved = await requestRoute(app, "POST", `/approvals/${approvedId}/decision`, JSON.stringify({ decision: "approve" }), { + "content-type": "application/json", + }); + expect(approved.status).toBe(200); + expect((await buildApprovalStore().get(approvedId))?.status).toBe("approved"); + + const deniedId = await requestFor("CHAT_DENY_TOKEN"); + const denied = await requestRoute(app, "POST", `/approvals/${deniedId}/decision`, JSON.stringify({ decision: "deny" }), { + "content-type": "application/json", + }); + expect(denied.status).toBe(200); + expect((await buildApprovalStore().get(deniedId))?.status).toBe("denied"); + }); + + it("fn_secret_get: production dashboard chat keeps its durable principal through a host secret call", async () => { + const cwd = h.rootDir(); + const tool = requireTool(freshApi(), "fn_secret_get"); + const secretsStore = injectSecretsStore(); + const app = createApprovalDecisionApp(); + const chatStore = { + getSession: vi.fn(() => ({ id: "chat-secret", agentId: "agent-1a009724", status: "active" })), + addMessage: vi.fn((message) => ({ id: `message-${message.role}`, ...message })), + getMessages: vi.fn(() => []), + setInFlightGeneration: vi.fn(async () => undefined), + updateSession: vi.fn(async () => undefined), + recordTokenUsage: vi.fn(async () => undefined), + }; + const agentStore = { + init: vi.fn(async () => undefined), + getAgent: vi.fn(async () => ({ + id: "agent-1a009724", + name: "Dashboard Chat Agent", + role: "executor", + runtimeConfig: {}, + })), + }; + const secretResults: Array>> = []; + + /* + FNXC:SecretsAccessApproval 2026-08-05-23:27: + This production-shaped fixture begins at ChatManager and invokes the real + createFnAgent prompt wrapper rather than manually creating an identity scope. + It verifies that dashboard durable-agent lookup reaches pi before fn_secret_get + receives an immediate context that deliberately omits agentId, then proves the + server-derived operator can both approve and deny separate requests. + */ + createPiAgentSessionMock.mockImplementation(async () => ({ + session: { + state: { messages: [{ role: "assistant", content: "Requesting secret access" }] }, + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + prompt: vi.fn(async (message: string) => { + const key = message.includes("deny") ? "DASHBOARD_CHAT_DENY_TOKEN" : "DASHBOARD_CHAT_APPROVE_TOKEN"; + // The host extension receives only cwd; createFnAgent's prompt wrapper + // must supply the durable principal for this real tool invocation. + secretResults.push(await tool.execute("dashboard-chat-secret", { key }, undefined, undefined, { cwd })); + }), + }, + })); + __setCreateResolvedAgentSession(async (options: any) => { + const { createFnAgent } = await import("../../../engine/src/pi.js"); + return createFnAgent({ ...options, tools: "coding" }) as any; + }); + + try { + await Promise.all([ + secretsStore.createSecret({ scope: "project", key: "DASHBOARD_CHAT_APPROVE_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }), + secretsStore.createSecret({ scope: "project", key: "DASHBOARD_CHAT_DENY_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }), + ]); + const manager = new ChatManager(chatStore as any, cwd, agentStore as any, undefined, undefined, undefined, h.store()); + await manager.sendMessage("chat-secret", "Read the prompt-gated secret"); + await manager.sendMessage("chat-secret", "Read and deny the prompt-gated secret"); + + expect(createPiAgentSessionMock).toHaveBeenCalledTimes(2); + const [approvedRequestId, deniedRequestId] = secretResults.map((result) => result.details?.approvalRequestId); + expect(approvedRequestId).toEqual(expect.any(String)); + expect(deniedRequestId).toEqual(expect.any(String)); + for (const requestId of [approvedRequestId, deniedRequestId]) { + const approval = await buildApprovalStore().get(requestId as string); + expect(approval?.requester).toMatchObject({ + actorId: "agent-1a009724", + actorType: "agent", + actorName: "Dashboard Chat Agent", + }); + } + + const approved = await requestRoute(app, "POST", `/approvals/${approvedRequestId}/decision`, JSON.stringify({ decision: "approve" }), { + "content-type": "application/json", + }); + expect(approved.status).toBe(200); + expect((await buildApprovalStore().get(approvedRequestId as string))?.status).toBe("approved"); + + const denied = await requestRoute(app, "POST", `/approvals/${deniedRequestId}/decision`, JSON.stringify({ decision: "deny" }), { + "content-type": "application/json", + }); + expect(denied.status).toBe(200); + expect((await buildApprovalStore().get(deniedRequestId as string))?.status).toBe("denied"); + } finally { + __resetChatState(); + } + }); + + it("fn_secret_get: direct human CLI remains a user requester after a session disposes", async () => { + const cwd = h.rootDir(); + const tool = requireTool(freshApi(), "fn_secret_get"); + const secretsStore = injectSecretsStore(); + await secretsStore.createSecret({ scope: "project", key: "CLI_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); + const dispose = registerFusionSessionIdentity(cwd, { agentId: "agent-disposed" }); + dispose(); + + const result = await tool.execute("cli-call", { key: "CLI_TOKEN" }, undefined, undefined, { cwd }); + const request = await buildApprovalStore().get(result.details?.approvalRequestId as string); + expect(request?.requester).toEqual({ actorId: "user", actorType: "user", actorName: "CLI User" }); + }); + + it("fn_secret_get: concurrent same-root registrations fail closed without minting a shared approval", async () => { + const cwd = h.rootDir(); + const tool = requireTool(freshApi(), "fn_secret_get"); + const secretsStore = injectSecretsStore(); + await secretsStore.createSecret({ scope: "project", key: "AMBIGUOUS_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); + const disposeA = registerFusionSessionIdentity(cwd, { agentId: "agent-a" }); + const disposeB = registerFusionSessionIdentity(cwd, { agentId: "agent-b" }); + try { + const result = await tool.execute("ambiguous-call", { key: "AMBIGUOUS_TOKEN" }, undefined, undefined, { cwd }); + expect(result.isError).toBe(true); + expect(result.details?.error).toBe("ambiguous-caller-identity"); + expect(await buildApprovalStore().list()).toHaveLength(0); + } finally { + disposeA(); + disposeB(); + } + }); + // ── fn_task_retry move source ──────────────────────────────────── it("fn_task_retry moves with the user/hard-cancel move source", async () => { diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 4525e4c933..b914a49e8e 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -795,6 +795,41 @@ type ExtensionCallerContext = { /** Stand-in agent id when the principal is ambiguous (multiple live sessions in one cwd). */ const AMBIGUOUS_AGENT_PRINCIPAL_ID = "unknown-agent"; +/** + * FNXC:SecretsAccessApproval 2026-08-05-21:31: + * Prompt-gated secret approvals must resolve one caller principal before every + * lifecycle operation. Dashboard chat's pi tool context omits `agentId`, but + * its engine-owned session registration proves the bound durable agent; treating + * that omission as the operator makes the operator self-approval guard permanently + * reject both decisions. Ambiguous cwd registrations deliberately fail closed: + * this tool refuses to mint or redeem a grant rather than sharing an approval + * between concurrent agents or collapsing either one into the operator. + */ +function resolveSecretAccessPrincipal(ctx: ExtensionCallerContext): + | { kind: "resolved"; actor: ApprovalRequestActorSnapshot; agentId: string | null; agentName?: string; taskId?: string } + | { kind: "ambiguous" } { + const principal = resolveExtensionCallerPrincipal(ctx); + if (principal.kind === "ambiguous") return { kind: "ambiguous" }; + if (principal.kind === "operator") { + return { + kind: "resolved", + actor: { actorId: "user", actorType: "user", actorName: "CLI User" }, + agentId: null, + }; + } + return { + kind: "resolved", + actor: { + actorId: principal.identity.agentId, + actorType: "agent", + actorName: principal.identity.agentName ?? principal.identity.agentId, + }, + agentId: principal.identity.agentId, + ...(principal.identity.agentName ? { agentName: principal.identity.agentName } : {}), + ...(principal.identity.taskId ? { taskId: principal.identity.taskId } : {}), + }; +} + /* FNXC:ToolPermissionGates 2026-07-26-13:55: Security incident root cause: all fn_* host-extension tools are delivered to engine agent @@ -3266,11 +3301,22 @@ export default function kbExtension(pi: ExtensionAPI) { scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("global")], { description: "Optional scope" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const fnCtx = ctx as typeof ctx & { - agentId?: string; - agentName?: string; - runId?: string; - taskId?: string; + const fnCtx = ctx as typeof ctx & ExtensionCallerContext; + const secretPrincipal = resolveSecretAccessPrincipal(fnCtx); + if (secretPrincipal.kind === "ambiguous") { + return { + content: [{ type: "text", text: "Secret access was not requested because the calling agent identity is ambiguous. End one concurrent session and retry." }], + isError: true, + details: { error: "ambiguous-caller-identity", key: params.key, scope: params.scope ?? null }, + }; + } + const effectiveCtx: { agentId?: string; agentName?: string; taskId?: string; runId?: string } = { + ...(secretPrincipal.agentId ? { agentId: secretPrincipal.agentId } : {}), + ...(secretPrincipal.agentName ? { agentName: secretPrincipal.agentName } : {}), + ...(typeof fnCtx.taskId === "string" + ? { taskId: fnCtx.taskId } + : secretPrincipal.taskId ? { taskId: secretPrincipal.taskId } : {}), + ...(typeof fnCtx.runId === "string" ? { runId: fnCtx.runId } : {}), }; const store = await getStore(ctx.cwd); const secretsStore = await store.getSecretsStore(); @@ -3298,7 +3344,7 @@ export default function kbExtension(pi: ExtensionAPI) { }); if (decision.policy === "deny") { - emitSecretAudit(store, fnCtx, "secret:approval-denied", `${resolvedScope}:${params.key}`); + emitSecretAudit(store, effectiveCtx, "secret:approval-denied", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: "Secret access denied by policy." }], details: { error: "denied", key: params.key, scope: resolvedScope, policySource: decision.source } }; } @@ -3319,25 +3365,13 @@ export default function kbExtension(pi: ExtensionAPI) { */ const cliLayer = requireProjectLayer(store, "CLI secret approval store"); const approvalStore = new ApprovalRequestStore(null, { asyncLayer: cliLayer }); - const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${fnCtx.agentId ?? "unknown"}`; - const requesterActorId = fnCtx.agentId ?? "user"; - /* - FNXC:SecretsAccessApproval 2026-07-26-18:35: - Review finding: a caller with no agentId is the human CLI operator, and - recording it as actorType "agent" mislabels the attribution this branch - exists to fix. Snapshot the real principal shape. - */ - const requesterSnapshot: ApprovalRequestActorSnapshot = fnCtx.agentId - ? { - actorId: requesterActorId, - actorType: "agent", - actorName: fnCtx.agentName ?? fnCtx.agentId, - } - : { actorId: "user", actorType: "user", actorName: "CLI User" }; - const existing = await findLatestApprovalRequestByDedupeKey(approvalStore, { requesterActorId, ...(fnCtx.taskId ? { taskId: fnCtx.taskId } : {}), dedupeKey }); + const requesterSnapshot = secretPrincipal.actor; + const requesterActorId = requesterSnapshot.actorId; + const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${requesterActorId}`; + const existing = await findLatestApprovalRequestByDedupeKey(approvalStore, { requesterActorId, ...(effectiveCtx.taskId ? { taskId: effectiveCtx.taskId } : {}), dedupeKey }); if (existing?.status === "pending") { - emitSecretAudit(store, fnCtx, "secret:approval-requested", `${resolvedScope}:${params.key}`); + emitSecretAudit(store, effectiveCtx, "secret:approval-requested", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: `Secret access approval request ${existing.id} is still pending. Approve via POST /api/approvals/:id/decision.` }], details: { outcome: "pending_approval", approvalRequestId: existing.id, key: params.key, scope: resolvedScope }, @@ -3345,7 +3379,7 @@ export default function kbExtension(pi: ExtensionAPI) { } if (existing?.status === "denied") { - emitSecretAudit(store, fnCtx, "secret:approval-denied", `${resolvedScope}:${params.key}`); + emitSecretAudit(store, effectiveCtx, "secret:approval-denied", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: `Secret access request ${existing.id} was denied by the operator. Do not retry without operator direction.` }], details: { outcome: "denied", approvalRequestId: existing.id, key: params.key, scope: resolvedScope }, @@ -3353,7 +3387,7 @@ export default function kbExtension(pi: ExtensionAPI) { } if (existing?.status === "approved") { - const revealedAfterApproval = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: fnCtx.agentId ?? null }); + const revealedAfterApproval = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: secretPrincipal.agentId }); await approvalStore.markCompleted(existing.id, { actor: requesterSnapshot, note: "Secret revealed after approval", @@ -3361,7 +3395,7 @@ export default function kbExtension(pi: ExtensionAPI) { // get the same expectedRequesterActorId enforcement as the gate path. expectedRequesterActorId: requesterActorId, }); - emitSecretAudit(store, fnCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope, approvalRequestId: existing.id }); + emitSecretAudit(store, effectiveCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope, approvalRequestId: existing.id }); return { content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope (approval ${existing.id} consumed).` }], details: { key: params.key, value: revealedAfterApproval.plaintextValue, scope: resolvedScope, approvalRequestId: existing.id }, @@ -3379,19 +3413,19 @@ export default function kbExtension(pi: ExtensionAPI) { resourceId: record.id, context: { approvalDedupeKey: dedupeKey, key: params.key, scope: resolvedScope }, }, - ...(fnCtx.runId ? { runId: fnCtx.runId } : {}), - ...(fnCtx.taskId ? { taskId: fnCtx.taskId } : {}), + ...(effectiveCtx.runId ? { runId: effectiveCtx.runId } : {}), + ...(effectiveCtx.taskId ? { taskId: effectiveCtx.taskId } : {}), }); - emitSecretAudit(store, fnCtx, "secret:approval-requested", `${resolvedScope}:${params.key}`); + emitSecretAudit(store, effectiveCtx, "secret:approval-requested", `${resolvedScope}:${params.key}`); return { content: [{ type: "text", text: `Secret access requires approval. Request ${request.id} is pending. Approve via POST /api/approvals/:id/decision.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, key: params.key, scope: resolvedScope }, }; } - const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: fnCtx.agentId ?? null }); - emitSecretAudit(store, fnCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope }); + const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: secretPrincipal.agentId }); + emitSecretAudit(store, effectiveCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope }); return { content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope.` }], details: { key: params.key, value: revealed.plaintextValue, scope: resolvedScope }, diff --git a/packages/core/src/__tests__/session-identity-registry.test.ts b/packages/core/src/__tests__/session-identity-registry.test.ts index 343a78aba7..8cccab4a0d 100644 --- a/packages/core/src/__tests__/session-identity-registry.test.ts +++ b/packages/core/src/__tests__/session-identity-registry.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { __clearFusionSessionIdentityRegistryForTests, registerFusionSessionIdentity, + runWithFusionSessionIdentity, resolveFusionSessionPrincipal, } from "../session-identity-registry.js"; @@ -44,6 +45,32 @@ describe("session identity registry", () => { expect(principal.kind).toBe("ambiguous"); }); + it("uses the invocation identity for concurrent sessions sharing a cwd", async () => { + const cwd = "/tmp/project-root"; + const disposeA = registerFusionSessionIdentity(cwd, { agentId: "agent-a" }); + const disposeB = registerFusionSessionIdentity(cwd, { agentId: "agent-b" }); + try { + const principals = await Promise.all([ + runWithFusionSessionIdentity([cwd], { agentId: "agent-a", purpose: "chat" }, async () => { + await Promise.resolve(); + return resolveFusionSessionPrincipal(cwd); + }), + runWithFusionSessionIdentity([cwd], { agentId: "agent-b", purpose: "chat" }, async () => { + await Promise.resolve(); + return resolveFusionSessionPrincipal(cwd); + }), + ]); + expect(principals).toEqual([ + expect.objectContaining({ kind: "agent", identity: expect.objectContaining({ agentId: "agent-a" }) }), + expect.objectContaining({ kind: "agent", identity: expect.objectContaining({ agentId: "agent-b" }) }), + ]); + expect(resolveFusionSessionPrincipal(cwd)).toEqual(expect.objectContaining({ kind: "ambiguous" })); + } finally { + disposeA(); + disposeB(); + } + }); + it("dispose is idempotent and only removes its own entry", () => { const disposeA = registerFusionSessionIdentity("/tmp/shared", { agentId: "agent-a" }); registerFusionSessionIdentity("/tmp/shared", { agentId: "agent-b" }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b8474eda21..8200f35be8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2703,10 +2703,12 @@ export { FNXC:SessionIdentity 2026-07-26-12:10: In-process principal channel between the engine (session spawner) and the bundled @runfusion/fusion pi extension (tool surface). Exported from core because both sides -inline core, while the actual state lives on globalThis so bundling cannot fork it. +inline core, while the registry and prompt-invocation context live on globalThis so +bundling cannot fork either channel. */ export { registerFusionSessionIdentity, + runWithFusionSessionIdentity, resolveFusionSessionPrincipal, __clearFusionSessionIdentityRegistryForTests, type FusionSessionIdentity, diff --git a/packages/core/src/session-identity-registry.ts b/packages/core/src/session-identity-registry.ts index 9df338753f..dd591dc867 100644 --- a/packages/core/src/session-identity-registry.ts +++ b/packages/core/src/session-identity-registry.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { resolve } from "node:path"; import { realpathSync } from "node:fs"; @@ -41,8 +42,13 @@ export type FusionSessionPrincipal = | { kind: "ambiguous"; identities: FusionSessionIdentity[] }; const REGISTRY_KEY = "__FUSION_SESSION_IDENTITY_REGISTRY_V1__"; +const ACTIVE_INVOCATION_KEY = "__FUSION_SESSION_IDENTITY_ACTIVE_INVOCATION_V1__"; type Registry = Map; +type ActiveInvocation = { + identity: FusionSessionIdentity; + cwdKeys: ReadonlySet; +}; function getRegistry(): Registry { const holder = globalThis as Record; @@ -54,6 +60,17 @@ function getRegistry(): Registry { return registry; } +function getActiveInvocationStorage(): AsyncLocalStorage { + const holder = globalThis as Record; + const existing = holder[ACTIVE_INVOCATION_KEY]; + if (existing instanceof AsyncLocalStorage) { + return existing as AsyncLocalStorage; + } + const storage = new AsyncLocalStorage(); + holder[ACTIVE_INVOCATION_KEY] = storage; + return storage; +} + /** * FNXC:SessionIdentity 2026-07-26-12:05: * Canonicalize before keying: macOS reports temp worktrees as both /var/... and @@ -107,8 +124,13 @@ export function registerFusionSessionIdentity( * agent, withhold operator-only capabilities). */ export function resolveFusionSessionPrincipal(cwd: string): FusionSessionPrincipal { + const canonicalCwd = canonicalizeCwd(cwd); + const activeInvocation = getActiveInvocationStorage().getStore(); + if (activeInvocation?.cwdKeys.has(canonicalCwd)) { + return { kind: "agent", identity: activeInvocation.identity }; + } const registry = getRegistry(); - const list = registry.get(canonicalizeCwd(cwd)); + const list = registry.get(canonicalCwd); if (!list || list.length === 0) { return { kind: "operator" }; } @@ -118,7 +140,28 @@ export function resolveFusionSessionPrincipal(cwd: string): FusionSessionPrincip return { kind: "ambiguous", identities: [...list] }; } +/* +FNXC:SecretsAccessApproval 2026-08-05-22:10: +A cwd registration is a safe fallback for non-invocation extension calls, but it is +ambiguous when concurrent sessions share a project root. Pi wraps each prompt in +this async context so a host tool with no immediate agentId receives the exact +session principal instead of an arbitrary root-level identity or an operator. +*/ +export function runWithFusionSessionIdentity( + cwdKeys: readonly string[], + identity: Omit, + callback: () => T, +): T { + const invocation: ActiveInvocation = { + identity: { ...identity, registeredAt: Date.now() }, + cwdKeys: new Set(cwdKeys.map(canonicalizeCwd)), + }; + return getActiveInvocationStorage().run(invocation, callback); +} + /** Test-only: wipe all registrations (isolated vitest workers share globalThis). */ export function __clearFusionSessionIdentityRegistryForTests(): void { - (globalThis as Record)[REGISTRY_KEY] = new Map(); + const holder = globalThis as Record; + holder[REGISTRY_KEY] = new Map(); + holder[ACTIVE_INVOCATION_KEY] = new AsyncLocalStorage(); } diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 66016350d8..3cb0c6ccb3 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -825,8 +825,15 @@ export function MailboxView({ setSelectedApproval(updated); setApprovalComment(""); addToast?.(`Request ${decision === "approve" ? "approved" : "denied"}`, "success"); - } catch { - addToast?.("Failed to submit decision", "error"); + } catch (error) { + /* + FNXC:SecretsAccessApproval 2026-08-05-21:31: + Approval decisions can fail for a server-enforced security invariant such as + genuine self-approval. Preserve a safe Error message so desktop and mobile + operators can act on it; unknown rejection shapes retain the generic fallback + and never expose raw response bodies, stacks, or secret material. + */ + addToast?.(error instanceof Error ? error.message : "Failed to submit decision", "error"); } finally { setApprovalDecisionLoading(false); } diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 655e5d78f0..7239e0e672 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -506,6 +506,55 @@ describe("MailboxView", () => { }); }); + it("surfaces the safe server decision error on the desktop approval controls", async () => { + const now = new Date().toISOString(); + mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 }); + mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 }); + mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now, requester: { actorId: "user", actorType: "user", actorName: "User" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] }); + mockDecideApproval.mockRejectedValue(new Error("An approval request cannot be decided by its own requester")); + const addToast = vi.fn(); + + render(); + await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith("An approval request cannot be decided by its own requester", "error")); + }); + + it("surfaces the safe server denial error on the mobile approval controls", async () => { + const now = new Date().toISOString(); + mockUseViewportMode.mockReturnValue("mobile"); + mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 }); + mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 }); + mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now, requester: { actorId: "user", actorType: "user", actorName: "User" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] }); + mockDecideApproval.mockRejectedValue(new Error("An approval request cannot be decided by its own requester")); + const addToast = vi.fn(); + + render(); + await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-deny")); }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith("An approval request cannot be decided by its own requester", "error")); + }); + + it("uses the generic decision error only for unknown rejections", async () => { + const now = new Date().toISOString(); + mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 }); + mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "agent-1", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 }); + mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "agent-1", createdAt: now, updatedAt: now, requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] }); + mockDecideApproval.mockRejectedValue("unknown failure"); + const addToast = vi.fn(); + + render(); + await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); }); + await act(async () => { fireEvent.click(await screen.getByTestId("mailbox-approval-deny")); }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to submit decision", "error")); + }); + it("disables decision buttons while submission is pending", async () => { const now = new Date().toISOString(); let resolveDecision: (() => void) | undefined; diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 900e4f6139..c6e78da4dc 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -8,6 +8,7 @@ FN-6444 confirmed this ChatManager API-path suite is deterministic under dashboa */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runWithFusionSessionIdentity, resolveFusionSessionPrincipal } from "@fusion/core"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; @@ -843,10 +844,22 @@ describe("ChatManager.sendMessage", () => { ); }); - it("passes action and permanent-agent gates to bound Mission chat sessions", async () => { + it("binds the durable dashboard-chat principal across the resolved-session host-tool invocation", async () => { let createOptions: any; + let hostToolPrincipal: unknown; __setCreateResolvedAgentSession(async (options: any) => { createOptions = options; + const identity = options.actionGateContext; + /* + FNXC:SecretsAccessApproval 2026-08-05-22:53: + The pi bridge runs host tools inside this invocation-scoped identity while + their immediate ExtensionContext has only cwd (no agentId). + */ + hostToolPrincipal = await runWithFusionSessionIdentity( + [options.cwd], + { agentId: identity.agentId, agentName: identity.agentName, purpose: options.sessionPurpose }, + () => resolveFusionSessionPrincipal(options.cwd), + ); return { session: { prompt: vi.fn().mockResolvedValue(undefined), @@ -880,10 +893,23 @@ describe("ChatManager.sendMessage", () => { receive the bound agent policy. The engine gating suites assert block and approval execution; this dashboard seam asserts chat cannot omit either context. */ + /* + FNXC:SecretsAccessApproval 2026-08-05-21:59: + Production dashboard chat must forward its bound durable agent through the + real resolved-session boundary. Pi then registers this context for host + extension calls whose immediate tool context omits agentId; without this + handoff prompt-gated secret requests are incorrectly attributed to user. + */ expect(createOptions.actionGateContext).toMatchObject({ agentId: "agent-001", + agentName: "Avery", + isEphemeral: false, permissionPolicy: { rules: { task_agent_mutation: "block" } }, }); + expect(hostToolPrincipal).toMatchObject({ + kind: "agent", + identity: { agentId: "agent-001", agentName: "Avery", purpose: "executor" }, + }); expect(createOptions.permanentAgentGating).toMatchObject({ requester: { actorId: "agent-001" }, permissionPolicy: { rules: { task_agent_mutation: "block" } }, diff --git a/packages/dashboard/src/routes/__tests__/register-approval-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-approval-routes.test.ts index 4e14b21584..45493fc393 100644 --- a/packages/dashboard/src/routes/__tests__/register-approval-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-approval-routes.test.ts @@ -205,6 +205,28 @@ describe("POST /api/approvals/:id/decision — server-derived decider", () => { expect(approvalState.decide).not.toHaveBeenCalled(); }); + it.each(["approve", "deny"] as const)("allows the server-derived operator to %s an agent-requested secrets approval", async (decision) => { + approvalState.requests.set(REQUEST_ID, makeApprovalRequest({ + requester: { actorId: "agent-1a009724", actorType: "agent", actorName: "Dashboard Chat Agent" }, + targetAction: { + category: "secrets_access", + action: "read", + summary: "Read secret", + resourceType: "secret", + resourceId: "secret-1", + }, + })); + const { app } = makeApp(); + const res = await postDecision(app, { decision }); + + expect(res.status).toBe(200); + expect(approvalState.decide).toHaveBeenCalledWith( + REQUEST_ID, + decision === "approve" ? "approved" : "denied", + expect.objectContaining({ actor: { actorId: "user", actorType: "user", actorName: "User" } }), + ); + }); + it("still rejects a malformed body actor with 400", async () => { const { app } = makeApp(); const res = await postDecision(app, { decision: "approve", actor: { actorId: 42 } }); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 95a56cdd15..5609522c5c 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1369,6 +1369,189 @@ describe("createFnAgent", () => { }); }); + it("binds a durable chat principal to the host-tool prompt invocation when pi omits agentId", async () => { + const { createFnAgent } = await import("../pi.js"); + const { + __clearFusionSessionIdentityRegistryForTests, + resolveFusionSessionPrincipal, + } = await import("@fusion/core"); + __clearFusionSessionIdentityRegistryForTests(); + const observedPrincipals: unknown[] = []; + createAgentSessionMock.mockResolvedValueOnce({ + session: { + prompt: vi.fn(async () => { + // This is the host-extension execution point: its immediate context has only cwd. + await Promise.resolve(); + observedPrincipals.push(resolveFusionSessionPrincipal("/project")); + }), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + }, + }); + + const { session } = await createFnAgent({ + cwd: "/project", + systemPrompt: "chat", + tools: "coding", + sessionPurpose: "executor", + actionGateContext: { + agentId: "agent-1a009724", + agentName: "Dashboard Chat Agent", + isEphemeral: false, + permissionPolicy: { presetId: "unrestricted", rules: {} }, + createApprovalRequest: vi.fn(), + findApprovalByDedupeKey: vi.fn(), + } as any, + }); + + /* + FNXC:SecretsAccessApproval 2026-08-05-22:10: + The regression boundary is an actual pi prompt invocation, not merely a + registered cwd. The host extension receives no agentId here, yet its + async execution resolves the durable chat agent through the invocation. + */ + // Dashboard chat uses the fallback-aware public prompt entry point. + await (session as any).promptWithFallback("read the prompt-gated secret"); + expect(observedPrincipals).toEqual([ + expect.objectContaining({ + kind: "agent", + identity: expect.objectContaining({ + agentId: "agent-1a009724", + agentName: "Dashboard Chat Agent", + purpose: "executor", + }), + }), + ]); + + await session.dispose?.(); + expect(resolveFusionSessionPrincipal("/project")).toEqual({ kind: "operator" }); + __clearFusionSessionIdentityRegistryForTests(); + }); + + it("reaches the pi host-tool identity wrapper from a durable dashboard chat session", async () => { + const { createFnAgent } = await import("../pi.js"); + const { + __clearFusionSessionIdentityRegistryForTests, + resolveFusionSessionPrincipal, + } = await import("@fusion/core"); + const { ChatManager, __resetChatState, __setCreateResolvedAgentSession } = await import("../../../dashboard/src/chat.js"); + __clearFusionSessionIdentityRegistryForTests(); + const observedPrincipals: unknown[] = []; + createAgentSessionMock.mockResolvedValueOnce({ + session: { + prompt: vi.fn(async () => { + // This mirrors a host-extension callback: pi supplies cwd but no agentId. + observedPrincipals.push(resolveFusionSessionPrincipal("/project")); + }), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + }, + }); + const chatStore = { + getSession: vi.fn(() => ({ id: "chat-secret", agentId: "agent-1a009724", status: "active" })), + addMessage: vi.fn((message) => ({ id: `message-${message.role}`, ...message })), + getMessages: vi.fn(() => []), + setInFlightGeneration: vi.fn(async () => undefined), + updateSession: vi.fn(async () => undefined), + recordTokenUsage: vi.fn(async () => undefined), + }; + const agentStore = { + init: vi.fn(async () => undefined), + getAgent: vi.fn(async () => ({ + id: "agent-1a009724", + name: "Dashboard Chat Agent", + role: "executor", + runtimeConfig: {}, + })), + }; + + /* + FNXC:SecretsAccessApproval 2026-08-05-23:17: + The dashboard must reach the real pi invocation wrapper, not a test-created + identity scope. This composes ChatManager's durable-agent lookup with its + resolved-session options and pi's host-tool prompt dispatch, where the + immediate extension context intentionally omits agentId. + */ + __setCreateResolvedAgentSession(async (options: any) => createFnAgent({ + ...options, + tools: "coding", + defaultProvider: "mock", + defaultModelId: "scripted", + }) as any); + + try { + const manager = new ChatManager( + chatStore as any, + "/project", + agentStore as any, + undefined, + undefined, + undefined, + { + getAsyncLayer: vi.fn(() => ({})), + getSettings: vi.fn(async () => ({ defaultAgentPermissionPolicy: { presetId: "unrestricted", rules: {} } })), + getFusionDir: () => "/project/.fusion", + } as any, + ); + await manager.sendMessage("chat-secret", "Read the prompt-gated secret"); + + expect(observedPrincipals).toEqual([ + expect.objectContaining({ + kind: "agent", + identity: expect.objectContaining({ + agentId: "agent-1a009724", + agentName: "Dashboard Chat Agent", + purpose: "executor", + }), + }), + ]); + } finally { + __resetChatState(); + __clearFusionSessionIdentityRegistryForTests(); + } + }); + + it("assigns distinct fail-closed principals to concurrent anonymous engine sessions", async () => { + const { createFnAgent } = await import("../pi.js"); + const { + __clearFusionSessionIdentityRegistryForTests, + resolveFusionSessionPrincipal, + } = await import("@fusion/core"); + __clearFusionSessionIdentityRegistryForTests(); + const observedPrincipals: Array<{ kind?: string; identity?: { agentId?: string } }> = []; + const makeSession = () => ({ + prompt: vi.fn(async () => { + const principal = resolveFusionSessionPrincipal("/project"); + observedPrincipals.push(principal as { kind?: string; identity?: { agentId?: string } }); + }), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + }); + createAgentSessionMock + .mockResolvedValueOnce({ session: makeSession() }) + .mockResolvedValueOnce({ session: makeSession() }); + + const [first, second] = await Promise.all([ + createFnAgent({ cwd: "/project", systemPrompt: "anonymous one", tools: "coding" }), + createFnAgent({ cwd: "/project", systemPrompt: "anonymous two", tools: "coding" }), + ]); + await Promise.all([ + (first.session as any).promptWithFallback("read secret one"), + (second.session as any).promptWithFallback("read secret two"), + ]); + + expect(observedPrincipals).toHaveLength(2); + expect(observedPrincipals).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "agent", identity: expect.objectContaining({ agentId: expect.stringMatching(/^engine-session-/) }) }), + ])); + expect(new Set(observedPrincipals.map((principal) => principal.identity?.agentId))).toHaveLength(2); + await Promise.all([first.session.dispose?.(), second.session.dispose?.()]); + __clearFusionSessionIdentityRegistryForTests(); + }); + it("skips host extensions for merger sessions so dual-store fn_* tools cannot wedge merge", async () => { /* FNXC:MergeQueue 2026-07-15-11:08: @@ -2638,9 +2821,14 @@ describe("createFnAgent", () => { debugSpy.mockRestore(); }); - it("falls back during prompt when the primary model has an auth failure", async () => { + it("retains a durable host-tool principal through a fallback session replacement", async () => { const primaryPrompt = vi.fn().mockRejectedValue(new Error("401 unauthorized: invalid api key")); - const fallbackPrompt = vi.fn().mockResolvedValue(undefined); + const observedFallbackPrincipals: unknown[] = []; + const fallbackPrompt = vi.fn(async () => { + const { resolveFusionSessionPrincipal } = await import("@fusion/core"); + // Pi's replacement session invokes host tools with only this cwd. + observedFallbackPrincipals.push(resolveFusionSessionPrincipal("/tmp")); + }); const primaryDispose = vi.fn(); createAgentSessionMock @@ -2662,6 +2850,8 @@ describe("createFnAgent", () => { }); const { createFnAgent } = await import("../pi.js"); + const { __clearFusionSessionIdentityRegistryForTests } = await import("@fusion/core"); + __clearFusionSessionIdentityRegistryForTests(); const { session } = await createFnAgent({ cwd: "/tmp", @@ -2671,6 +2861,14 @@ describe("createFnAgent", () => { defaultModelId: "glm-5.1", fallbackProvider: "openai-codex", fallbackModelId: "gpt-5.3-codex", + actionGateContext: { + agentId: "agent-1a009724", + agentName: "Dashboard Chat Agent", + isEphemeral: false, + permissionPolicy: { presetId: "unrestricted", rules: {} }, + createApprovalRequest: vi.fn(), + findApprovalByDedupeKey: vi.fn(), + } as any, }); await (session as any).promptWithFallback("make a spec"); @@ -2678,6 +2876,14 @@ describe("createFnAgent", () => { expect(primaryPrompt).toHaveBeenCalledWith("make a spec"); expect(primaryDispose).toHaveBeenCalled(); expect(fallbackPrompt).toHaveBeenCalledWith("make a spec"); + expect(observedFallbackPrincipals).toEqual([ + expect.objectContaining({ + kind: "agent", + identity: expect.objectContaining({ agentId: "agent-1a009724", agentName: "Dashboard Chat Agent" }), + }), + ]); + await session.dispose?.(); + __clearFusionSessionIdentityRegistryForTests(); expect(createAgentSessionMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ model: { provider: "zai", id: "glm-5.1" }, })); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 360622c3dd..cc4b3299d0 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -10,6 +10,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; import { exec, execFile } from "node:child_process"; import { promisify } from "node:util"; import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path"; const execAsync = promisify(exec); @@ -51,6 +52,7 @@ import { registerBuiltInGrokProvider, registerBuiltInZaiProvider, registerFusionSessionIdentity, + runWithFusionSessionIdentity, resolvePiExtensionProjectRoot, resolveToolOutputBudget, } from "@fusion/core"; @@ -2921,9 +2923,16 @@ export async function createFnAgent(options: AgentOptions): Promise attach and deregisters exactly once on its own dispose. */ const sessionIdentity = (() => { - const principalAgentId = options.actionGateContext?.agentId - ?? options.permanentAgentGating?.requester?.actorId - ?? "engine-session"; + const namedAgentId = options.actionGateContext?.agentId + ?? options.permanentAgentGating?.requester?.actorId; + /* + FNXC:SecretsAccessApproval 2026-08-05-22:44: + An engine-created session without a durable agent must still be a distinct + agent principal. A fixed synthetic id lets concurrent anonymous sessions + share a prompt-secret approval and redeem each other's grant; mint one + unguessable id per logical session and retain it across fallback swaps. + */ + const principalAgentId = namedAgentId ?? `engine-session-${randomUUID()}`; const principalAgentName = options.actionGateContext?.agentName ?? options.permanentAgentGating?.requester?.actorName; return { @@ -2936,6 +2945,23 @@ export async function createFnAgent(options: AgentOptions): Promise const sessionIdentityKeys = [...new Set([options.cwd, resolvedProjectRoot].filter((key): key is string => Boolean(key)))]; const attachSessionIdentity = (session: PromptableSession & { dispose?: () => void | Promise }): void => { const identityDisposers = sessionIdentityKeys.map((key) => registerFusionSessionIdentity(key, sessionIdentity)); + const sessionInvocations = session as unknown as Partial unknown>>; + const wrapInvocation = (methodName: "prompt" | "promptWithFallback"): void => { + const original = sessionInvocations[methodName]; + if (typeof original !== "function") return; + sessionInvocations[methodName] = (...args: unknown[]) => + runWithFusionSessionIdentity(sessionIdentityKeys, sessionIdentity, () => original.apply(session, args)); + }; + + /* + FNXC:SecretsAccessApproval 2026-08-05-22:10: + Host extensions execute inside pi's async prompt chain, but ExtensionContext + omits agentId. Bind the exact invocation principal before pi can dispatch a + tool: concurrent root-sharing sessions then retain their own requester rather + than falling back to an ambiguous cwd registry entry. + */ + wrapInvocation("prompt"); + wrapInvocation("promptWithFallback"); const disposeBeforeIdentity = typeof session.dispose === "function" ? session.dispose.bind(session) : () => undefined; @@ -3106,7 +3132,7 @@ export async function createFnAgent(options: AgentOptions): Promise distinguish agent principals from a human operator CLI. EVERY createFnAgent session is an LLM principal, never a human terminal, so registration is unconditional; the best-known agent identity comes from the action-gate or - permanent-gating contexts, falling back to a synthetic "engine-session" id + permanent-gating contexts, falling back to a unique synthetic per-session id that the extension must still treat as an agent (fail closed). Registered AFTER successful session construction (extension tools only run once the caller prompts, i.e. post-return), keyed under both the session cwd and the