From 2a0827835d0607ffc493e5c7c20350084a9ea03e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 5 Aug 2026 15:23:08 -0700 Subject: [PATCH] FN-8810: wire secrets stores into runtime worktrees Wire project secrets stores into runtime and dashboard heartbeat worktree acquisition. - Inject the project secrets store into executor and heartbeat monitors. - Cover in-process, UI-only dashboard, and secrets-env worktree materialization paths. - Add a patch changeset for restored secrets-env files. Files changed: .changeset/fn-8810-secrets-env-runtime-wiring.md | 7 ++ .../commands/__tests__/dashboard-supervise.test.ts | 28 ++++- packages/cli/src/commands/dashboard.ts | 38 ++++++- .../src/__tests__/in-process-runtime.pg.test.ts | 121 ++++++++++++++++++++- .../src/__tests__/secrets-env-writer.test.ts | 35 ++++++ .../worktree-acquisition-secrets-env.test.ts | 4 +- packages/engine/src/runtimes/in-process-runtime.ts | 9 ++ 7 files changed, 233 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-8810 Fusion-Task-Lineage: a67c3fbe-7744-4898-8a56-8739caa04479 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8810-secrets-env-runtime-wiring.md | 7 + .../__tests__/dashboard-supervise.test.ts | 28 +++- packages/cli/src/commands/dashboard.ts | 38 +++++- .../__tests__/in-process-runtime.pg.test.ts | 121 +++++++++++++++++- .../src/__tests__/secrets-env-writer.test.ts | 35 +++++ .../worktree-acquisition-secrets-env.test.ts | 4 +- .../engine/src/runtimes/in-process-runtime.ts | 9 ++ 7 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-8810-secrets-env-runtime-wiring.md diff --git a/.changeset/fn-8810-secrets-env-runtime-wiring.md b/.changeset/fn-8810-secrets-env-runtime-wiring.md new file mode 100644 index 0000000000..f13224d676 --- /dev/null +++ b/.changeset/fn-8810-secrets-env-runtime-wiring.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore secrets-env files in fresh task worktrees. +category: fix +dev: Runtime shares the project secrets store with executor and heartbeat worktree acquisition. diff --git a/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts index eb2df34d38..7bfceaf32a 100644 --- a/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it } from "vitest"; -import { classifyDashboardFatalExit, hasLiveSupervisingParent, resolveSupervisorRespawnCommand, shouldSuperviseDashboard } from "../dashboard.js"; +import { + buildUiOnlyHeartbeatMonitorOptions, + classifyDashboardFatalExit, + hasLiveSupervisingParent, + resolveSupervisorRespawnCommand, + shouldSuperviseDashboard, +} from "../dashboard.js"; import { FUSION_NON_RETRYABLE_EXIT_CODE } from "@fusion/core"; /* @@ -75,6 +81,26 @@ describe("hasLiveSupervisingParent", () => { }); }); +describe("UI-only heartbeat composition", () => { + it("preserves the project-scoped secrets store for fresh heartbeat worktrees", () => { + const agentStore = {} as any; + const taskStore = {} as any; + const secretsStore = { listEnvExportable: async () => [] }; + + const options = buildUiOnlyHeartbeatMonitorOptions({ + agentStore, + taskStore, + rootDir: "/project", + secretsStore, + }); + + expect(options.store).toBe(agentStore); + expect(options.agentStore).toBe(agentStore); + expect(options.taskStore).toBe(taskStore); + expect(options.secretsStore).toBe(secretsStore); + }); +}); + describe("classifyDashboardFatalExit", () => { it("stops unique-constraint failures without consuming restart attempts", () => { expect(classifyDashboardFatalExit({ cause: { code: "23505" } })).toEqual({ diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 3a808f483c..1407674539 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -7,6 +7,7 @@ import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { type TaskStore, + type SecretsStore, AutomationStore, CentralCore, AgentStore, @@ -153,6 +154,26 @@ import { // Re-export for backward compatibility with tests export { promptForPort }; +/* +FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:40: +UI-only dashboard heartbeats are a separate production composition root. Keep this +factory bound to the project-scoped store so fresh heartbeat worktrees never degrade to no-store. +*/ +export function buildUiOnlyHeartbeatMonitorOptions(input: { + agentStore: AgentStore; + taskStore: TaskStore; + rootDir: string; + secretsStore: Pick; +}) { + return { + store: input.agentStore, + agentStore: input.agentStore, + taskStore: input.taskStore, + rootDir: input.rootDir, + secretsStore: input.secretsStore, + }; +} + let processDiagnosticsRegistered = false; let diagnosticIntervalHandle: ReturnType | null = null; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -2494,11 +2515,20 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } try { + /* + FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:40: + UI-only dashboard mode still runs durable-agent heartbeat worktree acquisition. + Resolve this project's store before constructing that monitor so its fresh worktrees + materialize only env-exportable project secrets instead of silently taking no-store. + */ + const secretsStore = await store.getSecretsStore(); heartbeatMonitorImpl = new HeartbeatMonitor({ - store: agentStore, - agentStore, - taskStore: store, - rootDir: cwd, + ...buildUiOnlyHeartbeatMonitorOptions({ + agentStore, + taskStore: store, + rootDir: cwd, + secretsStore, + }), onMissed: (agentId, reason) => { logSink.warn(`Agent ${agentId} missed heartbeat: ${reason}`, "engine"); }, diff --git a/packages/engine/src/__tests__/in-process-runtime.pg.test.ts b/packages/engine/src/__tests__/in-process-runtime.pg.test.ts index 3d1a96e110..1bd71cef4c 100644 --- a/packages/engine/src/__tests__/in-process-runtime.pg.test.ts +++ b/packages/engine/src/__tests__/in-process-runtime.pg.test.ts @@ -4,7 +4,7 @@ The production InProcessRuntime must compose one owned PostgreSQL backend across */ import { execFileSync } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; @@ -13,7 +13,12 @@ import { pgDescribe, } from "../../../core/src/__test-utils__/pg-test-harness.js"; -const lifecycle = vi.hoisted(() => ({ shutdownCalls: 0 })); +const lifecycle = vi.hoisted(() => ({ + shutdownCalls: 0, + secretsStore: { listEnvExportable: vi.fn() }, + secretsStoreFailure: undefined as Error | undefined, + secretsStoreGetter: undefined as ReturnType | undefined, +})); vi.mock("@fusion/core", async (importOriginal) => { const actual = await importOriginal(); @@ -24,6 +29,12 @@ vi.mock("@fusion/core", async (importOriginal) => { ) => { const boot = await actual.createTaskStoreForBackend(options); const shutdown = boot.shutdown; + lifecycle.secretsStoreGetter = vi.spyOn(boot.taskStore, "getSecretsStore"); + if (lifecycle.secretsStoreFailure) { + lifecycle.secretsStoreGetter.mockRejectedValue(lifecycle.secretsStoreFailure); + } else { + lifecycle.secretsStoreGetter.mockResolvedValue(lifecycle.secretsStore as any); + } return { ...boot, shutdown: async () => { @@ -45,17 +56,27 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => { Runtime composition coverage must use the controlled PostgreSQL harness so availability gating and database administration share the repository's bounded asynchronous lifecycle. Runtime and central connections must close in a finally block before the harness drops the database, including when an assertion fails early. */ lifecycle.shutdownCalls = 0; + lifecycle.secretsStore.listEnvExportable.mockReset(); + lifecycle.secretsStoreFailure = undefined; + lifecycle.secretsStoreGetter = undefined; const harness = await createTaskStoreForTest({ prefix: "fusion_runtime" }); const priorDatabaseUrl = process.env.DATABASE_URL; let projectDir = ""; let globalDir = ""; let central: CentralCore | undefined; let runtime: InProcessRuntime | undefined; + let failedRuntime: InProcessRuntime | undefined; try { projectDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-project-")); globalDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-global-")); execFileSync("git", ["init", "-q", projectDir], { stdio: "pipe" }); + await writeFile(join(projectDir, ".gitignore"), ".secrets.env\n"); + await writeFile(join(projectDir, "README.md"), "runtime composition fixture\n"); + execFileSync("git", ["config", "user.email", "runtime-test@example.invalid"], { cwd: projectDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Fusion Runtime Test"], { cwd: projectDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: projectDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-qm", "initialize runtime composition fixture"], { cwd: projectDir, stdio: "pipe" }); process.env.DATABASE_URL = harness.testUrl; central = new CentralCore(globalDir); @@ -83,6 +104,81 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => { expect(runtimeInternals.triageProcessor?.options?.usageLimitPauser) .toBe(runtimeInternals.usageLimitPauser); + /* + FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:30: + Production composition resolves the project store once and gives the exact instance to + both fresh-worktree consumers. Their existing acquisition coverage verifies the writer; + this seam test prevents an omitted runtime dependency from silently becoming no-store. + */ + const secretsStore = await lifecycle.secretsStoreGetter?.mock.results[0]?.value; + const runtimeConsumers = runtime as unknown as { + executor?: { options?: { secretsStore?: unknown } }; + heartbeatMonitor?: { secretsStore?: unknown }; + }; + expect(lifecycle.secretsStoreGetter).toHaveBeenCalled(); + expect(runtimeConsumers.executor?.options?.secretsStore).toBe(secretsStore); + expect(runtimeConsumers.heartbeatMonitor?.secretsStore).toBe(secretsStore); + + /* + FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:58: + Production coverage must invoke the runtime-created executor and heartbeat monitor, not + extract their dependency into a helper call. Each consumer owns a distinct fresh-worktree + path; both must materialize the ignored file and record a redacted write audit. + */ + await taskStore.updateSettings({ + testMode: true, + secretsEnv: { enabled: true, filename: ".secrets.env" }, + }); + lifecycle.secretsStore.listEnvExportable.mockResolvedValue([ + { + id: "runtime-secret", + key: "runtime-key", + exportKey: "RUNTIME_SECRET", + scope: "project", + plaintextValue: "runtime-test-value", + }, + ]); + const assertConsumerMaterializedSecretsEnv = async (taskId: string) => { + const task = await taskStore.getTask(taskId); + expect(task?.worktree).toEqual(expect.any(String)); + const exportedKeys = (await readFile(join(task!.worktree!, ".secrets.env"), "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => line.split("=", 1)[0]); + expect(exportedKeys).toContain("RUNTIME_SECRET"); + const auditEvents = await taskStore.getRunAuditEventsAsync(); + const writeEvent = auditEvents.find((event) => + event.target === taskId && event.mutationType === "secret:env-write", + ); + expect(writeEvent?.metadata).toMatchObject({ keyCount: 1, fingerprint: expect.any(String) }); + expect(auditEvents.some((event) => + event.target === taskId + && event.mutationType === "secret:env-write-skipped" + && event.metadata?.reason === "no-store", + )).toBe(false); + }; + + const executorTask = await taskStore.createTask({ description: "executor secrets env" }); + await (runtime.getExecutor() as any).ensureGraphCustomNodeWorktree( + executorTask, + await taskStore.getSettings(), + "test-worktree", + ); + await assertConsumerMaterializedSecretsEnv(executorTask.id); + + const heartbeatTask = await taskStore.createTask({ description: "heartbeat secrets env" }); + const agentStore = runtime.getAgentStore()!; + const heartbeatAgent = await agentStore.createAgent({ + name: "Runtime secrets-env heartbeat agent", + role: "executor", + }); + await agentStore.assignTask(heartbeatAgent.id, heartbeatTask.id); + await runtime.getHeartbeatMonitor()!.executeHeartbeat({ + agentId: heartbeatAgent.id, + source: "on_demand", + }); + await assertConsumerMaterializedSecretsEnv(heartbeatTask.id); + const missionStore = taskStore.getMissionStore(); const mission = await missionStore.createMission({ title: "Runtime composition" }); expect((await missionStore.getMission(mission.id))?.title).toBe("Runtime composition"); @@ -103,6 +199,25 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => { await runtime.stop(); expect(runtime.getStatus()).toBe("stopped"); expect(lifecycle.shutdownCalls).toBe(1); + + /* + FNXC:SecretsEnvRuntimeWiring 2026-08-05-22:12: + Secrets-store resolution is an essential composition dependency, not a best-effort + secretsEnv convenience. A rejection must take the normal fail-closed startup cleanup + path so no partial executor or heartbeat runtime remains active. + */ + lifecycle.secretsStoreFailure = new Error("test secrets-store initialization failure"); + failedRuntime = new InProcessRuntime({ + projectId: "runtime-composition-secrets-store-failure", + workingDirectory: projectDir, + isolationMode: "in-process", + maxConcurrent: 1, + maxWorktrees: 1, + }, central); + failedRuntime.on("error", () => undefined); + await expect(failedRuntime.start()).rejects.toThrow("test secrets-store initialization failure"); + expect(failedRuntime.getStatus()).toBe("errored"); + expect(lifecycle.shutdownCalls).toBe(2); } finally { try { await runtime?.stop(); @@ -120,6 +235,8 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => { globalDir ? rm(globalDir, { recursive: true, force: true }) : Promise.resolve(), ]); lifecycle.shutdownCalls = 0; + lifecycle.secretsStoreFailure = undefined; + lifecycle.secretsStoreGetter = undefined; } } } diff --git a/packages/engine/src/__tests__/secrets-env-writer.test.ts b/packages/engine/src/__tests__/secrets-env-writer.test.ts index 3d005e9884..eb86461ba8 100644 --- a/packages/engine/src/__tests__/secrets-env-writer.test.ts +++ b/packages/engine/src/__tests__/secrets-env-writer.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import { join } from "node:path"; @@ -85,6 +86,40 @@ describe("secrets-env-writer", () => { expect(outputBlob).not.toContain(secretValue); }); + it("writes an ignored configured file and records a redacted production audit", async () => { + const dir = tmpWorktree(); + const filesystem = vi.fn(); + const secretValue = "runtime-materialized-secret"; + execFileSync("git", ["init", "-q"], { cwd: dir }); + writeFileSync(join(dir, ".gitignore"), ".secrets.env\n"); + + const result = await writeSecretsEnvFile({ + rootDir: dir, + worktreePath: dir, + taskId: "FN-8810", + settings: { secretsEnv: { enabled: true, filename: ".secrets.env" } }, + worktreeSource: "fresh", + audit: { filesystem }, + secretsStore: { + listEnvExportable: vi.fn().mockResolvedValue([ + { id: "1", key: "runtime-key", exportKey: "RUNTIME_SECRET", scope: "project", plaintextValue: secretValue }, + ]), + } as any, + }); + + expect(result).toMatchObject({ outcome: "written", filename: ".secrets.env", keyCount: 1 }); + const exportedKeys = readFileSync(join(dir, ".secrets.env"), "utf8") + .split("\n") + .filter(Boolean) + .map((line) => line.split("=", 1)[0]); + expect(exportedKeys).toContain("RUNTIME_SECRET"); + expect(filesystem).toHaveBeenCalledWith(expect.objectContaining({ + type: "secret:env-write", + metadata: expect.objectContaining({ keyCount: 1, fingerprint: expect.any(String) }), + })); + expect(JSON.stringify(filesystem.mock.calls)).not.toContain(secretValue); + }); + it("merge is idempotent", async () => { const dir = tmpWorktree(); writeFileSync(join(dir, ".env"), "EXISTING=1\n"); diff --git a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts index 35d3cc4b93..05f59379b5 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts @@ -9,7 +9,7 @@ vi.mock("../worktree/secrets-env-writer.js", () => ({ })); vi.mock("../worktree/worktree-pool.js", async () => { - const actual = await vi.importActual("../worktree-pool.js"); + const actual = await vi.importActual("../worktree/worktree-pool.js"); return { ...actual, classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }), @@ -26,7 +26,7 @@ FNXC:EngineTests 2026-07-21-00:10: Pool unit tests use non-git temp paths; identity guard would throw and fall through to fresh. */ vi.mock("../worktree/worktree-hooks.js", async () => { - const actual = await vi.importActual("../worktree-hooks.js"); + const actual = await vi.importActual("../worktree/worktree-hooks.js"); return { ...actual, installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined), diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index c4b4fff5fe..20adcf8278 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1321,6 +1321,13 @@ export class InProcessRuntime } const prNodeGithubOps = this.config.prNodeGithubOps; + /* + FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:30: + `secretsEnv` materializes only through fresh executor and heartbeat worktree acquisitions. + Resolve the project-scoped store once at this composition boundary and share it with both + consumers; omitting either dependency silently degrades that path to the defensive no-store skip. + */ + const secretsStore = await this.taskStore.getSecretsStore(); const executorOptions: TaskExecutorOptions = { /* FNXC:PlanReviewLease 2026-07-26-21:12: @@ -1336,6 +1343,7 @@ export class InProcessRuntime cliAgentRuntime: this.cliAgentRuntime?.bundle, pluginRunner: this.pluginRunner, messageStore: this.messageStore, + secretsStore, missionStore, reflectionService, // PR-entity nodes (U3): assemble the handler deps from the CLI-injected @@ -1468,6 +1476,7 @@ export class InProcessRuntime reflectionService, selfImproveService, credentialRotator: this.credentialRotator, + secretsStore, snapshotManager: autoClaimSnapshotManager, onMissed: (agentId, reason) => { runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`);