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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 15:23:08 -07:00
parent 6ccde2b0d8
commit 2a0827835d
7 changed files with 233 additions and 9 deletions

View File

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

View File

@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it } from "vitest"; 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"; 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", () => { describe("classifyDashboardFatalExit", () => {
it("stops unique-constraint failures without consuming restart attempts", () => { it("stops unique-constraint failures without consuming restart attempts", () => {
expect(classifyDashboardFatalExit({ cause: { code: "23505" } })).toEqual({ expect(classifyDashboardFatalExit({ cause: { code: "23505" } })).toEqual({

View File

@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
type TaskStore, type TaskStore,
type SecretsStore,
AutomationStore, AutomationStore,
CentralCore, CentralCore,
AgentStore, AgentStore,
@@ -153,6 +154,26 @@ import {
// Re-export for backward compatibility with tests // Re-export for backward compatibility with tests
export { promptForPort }; 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<SecretsStore, "listEnvExportable">;
}) {
return {
store: input.agentStore,
agentStore: input.agentStore,
taskStore: input.taskStore,
rootDir: input.rootDir,
secretsStore: input.secretsStore,
};
}
let processDiagnosticsRegistered = false; let processDiagnosticsRegistered = false;
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null; let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -2494,11 +2515,20 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
try { 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({ heartbeatMonitorImpl = new HeartbeatMonitor({
store: agentStore, ...buildUiOnlyHeartbeatMonitorOptions({
agentStore, agentStore,
taskStore: store, taskStore: store,
rootDir: cwd, rootDir: cwd,
secretsStore,
}),
onMissed: (agentId, reason) => { onMissed: (agentId, reason) => {
logSink.warn(`Agent ${agentId} missed heartbeat: ${reason}`, "engine"); logSink.warn(`Agent ${agentId} missed heartbeat: ${reason}`, "engine");
}, },

View File

@@ -4,7 +4,7 @@ The production InProcessRuntime must compose one owned PostgreSQL backend across
*/ */
import { execFileSync } from "node:child_process"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
@@ -13,7 +13,12 @@ import {
pgDescribe, pgDescribe,
} from "../../../core/src/__test-utils__/pg-test-harness.js"; } 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<typeof vi.spyOn> | undefined,
}));
vi.mock("@fusion/core", async (importOriginal) => { vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>(); const actual = await importOriginal<typeof import("@fusion/core")>();
@@ -24,6 +29,12 @@ vi.mock("@fusion/core", async (importOriginal) => {
) => { ) => {
const boot = await actual.createTaskStoreForBackend(options); const boot = await actual.createTaskStoreForBackend(options);
const shutdown = boot.shutdown; 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 { return {
...boot, ...boot,
shutdown: async () => { 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. 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.shutdownCalls = 0;
lifecycle.secretsStore.listEnvExportable.mockReset();
lifecycle.secretsStoreFailure = undefined;
lifecycle.secretsStoreGetter = undefined;
const harness = await createTaskStoreForTest({ prefix: "fusion_runtime" }); const harness = await createTaskStoreForTest({ prefix: "fusion_runtime" });
const priorDatabaseUrl = process.env.DATABASE_URL; const priorDatabaseUrl = process.env.DATABASE_URL;
let projectDir = ""; let projectDir = "";
let globalDir = ""; let globalDir = "";
let central: CentralCore | undefined; let central: CentralCore | undefined;
let runtime: InProcessRuntime | undefined; let runtime: InProcessRuntime | undefined;
let failedRuntime: InProcessRuntime | undefined;
try { try {
projectDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-project-")); projectDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-project-"));
globalDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-global-")); globalDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-global-"));
execFileSync("git", ["init", "-q", projectDir], { stdio: "pipe" }); 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; process.env.DATABASE_URL = harness.testUrl;
central = new CentralCore(globalDir); central = new CentralCore(globalDir);
@@ -83,6 +104,81 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => {
expect(runtimeInternals.triageProcessor?.options?.usageLimitPauser) expect(runtimeInternals.triageProcessor?.options?.usageLimitPauser)
.toBe(runtimeInternals.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 missionStore = taskStore.getMissionStore();
const mission = await missionStore.createMission({ title: "Runtime composition" }); const mission = await missionStore.createMission({ title: "Runtime composition" });
expect((await missionStore.getMission(mission.id))?.title).toBe("Runtime composition"); expect((await missionStore.getMission(mission.id))?.title).toBe("Runtime composition");
@@ -103,6 +199,25 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => {
await runtime.stop(); await runtime.stop();
expect(runtime.getStatus()).toBe("stopped"); expect(runtime.getStatus()).toBe("stopped");
expect(lifecycle.shutdownCalls).toBe(1); 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 { } finally {
try { try {
await runtime?.stop(); await runtime?.stop();
@@ -120,6 +235,8 @@ pgDescribe("InProcessRuntime PostgreSQL composition", () => {
globalDir ? rm(globalDir, { recursive: true, force: true }) : Promise.resolve(), globalDir ? rm(globalDir, { recursive: true, force: true }) : Promise.resolve(),
]); ]);
lifecycle.shutdownCalls = 0; lifecycle.shutdownCalls = 0;
lifecycle.secretsStoreFailure = undefined;
lifecycle.secretsStoreGetter = undefined;
} }
} }
} }

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs"; import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@@ -85,6 +86,40 @@ describe("secrets-env-writer", () => {
expect(outputBlob).not.toContain(secretValue); 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 () => { it("merge is idempotent", async () => {
const dir = tmpWorktree(); const dir = tmpWorktree();
writeFileSync(join(dir, ".env"), "EXISTING=1\n"); writeFileSync(join(dir, ".env"), "EXISTING=1\n");

View File

@@ -9,7 +9,7 @@ vi.mock("../worktree/secrets-env-writer.js", () => ({
})); }));
vi.mock("../worktree/worktree-pool.js", async () => { vi.mock("../worktree/worktree-pool.js", async () => {
const actual = await vi.importActual<any>("../worktree-pool.js"); const actual = await vi.importActual<any>("../worktree/worktree-pool.js");
return { return {
...actual, ...actual,
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }), 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. Pool unit tests use non-git temp paths; identity guard would throw and fall through to fresh.
*/ */
vi.mock("../worktree/worktree-hooks.js", async () => { vi.mock("../worktree/worktree-hooks.js", async () => {
const actual = await vi.importActual<any>("../worktree-hooks.js"); const actual = await vi.importActual<any>("../worktree/worktree-hooks.js");
return { return {
...actual, ...actual,
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined), installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),

View File

@@ -1321,6 +1321,13 @@ export class InProcessRuntime
} }
const prNodeGithubOps = this.config.prNodeGithubOps; 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 = { const executorOptions: TaskExecutorOptions = {
/* /*
FNXC:PlanReviewLease 2026-07-26-21:12: FNXC:PlanReviewLease 2026-07-26-21:12:
@@ -1336,6 +1343,7 @@ export class InProcessRuntime
cliAgentRuntime: this.cliAgentRuntime?.bundle, cliAgentRuntime: this.cliAgentRuntime?.bundle,
pluginRunner: this.pluginRunner, pluginRunner: this.pluginRunner,
messageStore: this.messageStore, messageStore: this.messageStore,
secretsStore,
missionStore, missionStore,
reflectionService, reflectionService,
// PR-entity nodes (U3): assemble the handler deps from the CLI-injected // PR-entity nodes (U3): assemble the handler deps from the CLI-injected
@@ -1468,6 +1476,7 @@ export class InProcessRuntime
reflectionService, reflectionService,
selfImproveService, selfImproveService,
credentialRotator: this.credentialRotator, credentialRotator: this.credentialRotator,
secretsStore,
snapshotManager: autoClaimSnapshotManager, snapshotManager: autoClaimSnapshotManager,
onMissed: (agentId, reason) => { onMissed: (agentId, reason) => {
runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`); runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`);