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:
7
.changeset/fn-8810-secrets-env-runtime-wiring.md
Normal file
7
.changeset/fn-8810-secrets-env-runtime-wiring.md
Normal 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user