FN-8809: preserve agent principals for secret approvals

Keep prompt-gated secret approvals bound to their calling chat-agent session.

- Resolve extension secret callers through async session identity context.
- Give anonymous engine sessions unique principals and preserve them across tool invocations.
- Surface actionable approval decision errors in the mailbox and cover approval flows.

Files changed:
 .changeset/fn-8809-secrets-chat-approval.md        |   7 +
 .../__tests__/extension-permission-gates.test.ts   | 257 ++++++++++++++++++++-
 packages/cli/src/extension.ts                      |  96 +++++---
 .../__tests__/session-identity-registry.test.ts    |  27 +++
 packages/core/src/index.ts                         |   4 +-
 packages/core/src/session-identity-registry.ts     |  47 +++-
 packages/dashboard/app/components/MailboxView.tsx  |  11 +-
 .../app/components/__tests__/MailboxView.test.tsx  |  49 ++++
 .../dashboard/src/__tests__/chat-manager.test.ts   |  28 ++-
 .../__tests__/register-approval-routes.test.ts     |  22 ++
 .../src/__tests__/pi-create-fn-agent.test.ts       | 210 ++++++++++++++++-
 packages/engine/src/pi.ts                          |  34 ++-
 12 files changed, 748 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-8809

Fusion-Task-Lineage: 2e070f78-7215-4401-bcf2-6fa25fa27066

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 16:46:38 -07:00
parent 00fdbe1bef
commit 1db7adcdb0
12 changed files with 748 additions and 44 deletions

View File

@@ -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.

View File

@@ -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<Awaited<ReturnType<typeof tool.execute>>> = [];
/*
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 () => {

View File

@@ -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 },

View File

@@ -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" });

View File

@@ -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,

View File

@@ -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<string, FusionSessionIdentity[]>;
type ActiveInvocation = {
identity: FusionSessionIdentity;
cwdKeys: ReadonlySet<string>;
};
function getRegistry(): Registry {
const holder = globalThis as Record<string, unknown>;
@@ -54,6 +60,17 @@ function getRegistry(): Registry {
return registry;
}
function getActiveInvocationStorage(): AsyncLocalStorage<ActiveInvocation> {
const holder = globalThis as Record<string, unknown>;
const existing = holder[ACTIVE_INVOCATION_KEY];
if (existing instanceof AsyncLocalStorage) {
return existing as AsyncLocalStorage<ActiveInvocation>;
}
const storage = new AsyncLocalStorage<ActiveInvocation>();
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<T>(
cwdKeys: readonly string[],
identity: Omit<FusionSessionIdentity, "registeredAt">,
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<string, unknown>)[REGISTRY_KEY] = new Map();
const holder = globalThis as Record<string, unknown>;
holder[REGISTRY_KEY] = new Map();
holder[ACTIVE_INVOCATION_KEY] = new AsyncLocalStorage<ActiveInvocation>();
}

View File

@@ -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);
}

View File

@@ -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(<MailboxView {...defaultProps} addToast={addToast} />);
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(<MailboxView {...defaultProps} addToast={addToast} />);
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(<MailboxView {...defaultProps} addToast={addToast} />);
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;

View File

@@ -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" } },

View File

@@ -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 } });

View File

@@ -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" },
}));

View File

@@ -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<AgentResult>
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<AgentResult>
const sessionIdentityKeys = [...new Set([options.cwd, resolvedProjectRoot].filter((key): key is string => Boolean(key)))];
const attachSessionIdentity = (session: PromptableSession & { dispose?: () => void | Promise<void> }): void => {
const identityDisposers = sessionIdentityKeys.map((key) => registerFusionSessionIdentity(key, sessionIdentity));
const sessionInvocations = session as unknown as Partial<Record<"prompt" | "promptWithFallback", (...args: unknown[]) => 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<AgentResult>
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