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 { 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({

View File

@@ -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<SecretsStore, "listEnvExportable">;
}) {
return {
store: input.agentStore,
agentStore: input.agentStore,
taskStore: input.taskStore,
rootDir: input.rootDir,
secretsStore: input.secretsStore,
};
}
let processDiagnosticsRegistered = false;
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | 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");
},

View File

@@ -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<typeof vi.spyOn> | undefined,
}));
vi.mock("@fusion/core", async (importOriginal) => {
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 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;
}
}
}

View File

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

View File

@@ -9,7 +9,7 @@ vi.mock("../worktree/secrets-env-writer.js", () => ({
}));
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 {
...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<any>("../worktree-hooks.js");
const actual = await vi.importActual<any>("../worktree/worktree-hooks.js");
return {
...actual,
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),

View File

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