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:
7
.changeset/fn-8809-secrets-chat-approval.md
Normal file
7
.changeset/fn-8809-secrets-chat-approval.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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" },
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user