feat(FN-5035): merge fusion/fn-5035

This commit is contained in:
gsxdsm
2026-05-18 18:07:53 -07:00
parent f46629ef10
commit af7367ab99
9 changed files with 128 additions and 24 deletions

View File

@@ -272,8 +272,7 @@ Public API surface:
Settings boundary:
- Global default policy: `GlobalSettings.secretsAccessPolicy` (used by `resolveSecretAccessPolicy`).
- Project-level secrets settings: `ProjectSettings.secretsEnv` and `ProjectSettings.secretsSyncPassphrase` (`packages/core/src/types.ts:2599-2609`).
- `secretsSyncPassphrase` must be pre-wrapped ciphertext under local master key by caller (`packages/core/src/types.ts:2602-2604`).
- Project-level secrets settings: `ProjectSettings.secretsEnv`. Cross-node sync passphrase state surfaces read-only via `GlobalSettings.secretsSyncPassphraseConfigured` (derived from `hasSyncPassphraseConfigured(secretsStore)` against the reserved `__sync_passphrase__` row in `secrets_global`).
- Agent secret reads are exposed via `fn_secret_get` (`packages/cli/src/extension.ts:1542-1629`).
- Cross-node sync routes ship at `/api/nodes/:id/secrets/push`, `/api/nodes/:id/secrets/pull`, `/api/secrets/sync-receive`, `/api/secrets/sync-export` with inbound Bearer apiKey validation (`packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts:99-114`, `:181-196`).

View File

@@ -34,16 +34,16 @@ Auth/ownership parity between settings-sync and secrets-sync routes is **impleme
## 4) Multi-node Behavior
- Shared passphrase requirement is implementation-enforced via local reserved secret lookup (`getSyncPassphrase`) and unwrap failure semantics (`packages/core/src/secrets-sync-passphrase.ts:9-24`, `packages/core/src/secrets-sync.ts:107-110`). Docs state both nodes must share passphrase (`docs/secrets.md:115-121`).
- Planning expected a `secretsSyncPassphrase` settings key, but shipped implementation uses reserved secret `__sync_passphrase__` + `getSyncPassphrase` instead (`packages/core/src/secrets-sync-passphrase.ts:1-24`). `docs/settings-reference.md` still labels `secretsSyncPassphrase` as planned (`docs/settings-reference.md:91,198`).
- Planning expected a writable settings key, but shipped implementation stores the passphrase only in reserved secret `__sync_passphrase__` and now exposes state through read-only `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/secrets-sync-passphrase.ts:1-24`).
- No dedicated dashboard passphrase UX surfaced in routes/components reviewed; configuration remains implicit through secret storage primitives, so multi-node setup discoverability is partial.
- Pull path handles remote envelope version mismatch because `unwrapSecretsBundle` throws `version-mismatch` on non-v1 (`packages/core/src/secrets-sync.ts:82-84`) and route maps `SecretsSyncError.code` to 400 (`register-secrets-sync-routes.ts:139-144`).
- Audit payload hygiene: push logs only `{ nodeId, recordCount }` (`register-secrets-sync-routes.ts:92`); pull logs `{ nodeId, key, scope }` (`register-secrets-sync-routes.ts:180`); inbound receive logs `{ nodeId, key, scope }` (`register-secrets-sync-inbound-routes.ts:155-159`). No plaintext/ciphertext/passphrase fields are emitted.
- `secretsSyncPassphrase` does not cross wire in route payload construction: outbound push sends envelope + `sourceNodeId`/`exportedAt` only (`register-secrets-sync-routes.ts:83-89`); inbound export returns envelope + metadata (`register-secrets-sync-inbound-routes.ts:209-213`). Passphrase itself is only used locally in wrap/unwrap calls.
- Sync passphrase material does not cross wire in route payload construction: outbound push sends envelope + `sourceNodeId`/`exportedAt` only (`register-secrets-sync-routes.ts:83-89`); inbound export returns envelope + metadata (`register-secrets-sync-inbound-routes.ts:209-213`). Passphrase material is only used locally in wrap/unwrap calls.
## 5) Gaps & Follow-ups
- [FN-4981] Add secrets-sync tests for outbound missing-remote-apiKey failure paths for push/pull. ✅ Landed (routes-secrets-sync now covers undefined/""/null apiKey plus guard-ordering invariant).
- [FN-4980] Add secrets-sync tests for full inbound auth-cascade on `GET /api/secrets/sync-export`.
- [FN-4982] Update stale settings-reference rows that still mark `secretsSyncPassphrase` as planned.
- [FN-4982/FN-5035] Reconciled settings-reference docs with implementation: read-only global probe (`secretsSyncPassphraseConfigured`) + reserved-secret storage model.
- [FN-4984] Add/clarify dashboard UX for configuring the shared sync passphrase (or equivalent guided setup).
## 6) References

View File

@@ -20,7 +20,7 @@ Fusion's secrets subsystem provides encrypted-at-rest secret storage with projec
| `secrets-sync.ts` wrap/unwrap core (scrypt → AES-256-GCM, version 1, typed errors) | Shipped | — | `packages/core/src/secrets-sync.ts` |
| Dashboard `SecretsView` CRUD UI | Shipped | — | `packages/dashboard/app/components/SecretsView.tsx` |
| `secretsEnv.*` settings + worktree `.env` materialization + fingerprint cleanup | Shipped | — | `packages/core/src/types.ts`, `packages/engine/src/secrets-env-writer.ts` |
| `secretsSyncPassphrase` project setting integration | Shipped | — | `packages/core/src/types.ts`, `packages/dashboard/src/routes/register-secrets-sync-routes.ts` |
| `secretsSyncPassphraseConfigured` global read-only probe + reserved secret storage (`__sync_passphrase__`) | Shipped | — | `packages/core/src/types.ts`, `packages/core/src/secrets-sync-passphrase.ts` |
| Cross-node sync REST endpoints (`/api/nodes/:id/secrets/{push,pull}`, `/api/secrets/sync-receive`, `/api/secrets/sync-export`) | Shipped | — | `packages/dashboard/src/routes/register-secrets-sync-routes.ts`, `packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts` |
| Audit-event registration on `FilesystemMutationType` for `secret:env-*` and `secret:sync-*` | Shipped | — | `packages/engine/src/run-audit.ts` |
| Master-key rotation UX | Pending | — | n/a |
@@ -133,14 +133,15 @@ Fusion can materialize env-exportable secrets into each acquired task worktree w
- Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` (mode `0o600`) so teardown can verify file integrity before deletion.
- Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed.
Settings shape is project-scoped in `ProjectSettings` (`packages/core/src/types.ts:2599-2609`): `secretsEnv` (env materialization config) and `secretsSyncPassphrase` (ciphertext already wrapped under local master key by caller; see `types.ts:2602-2604`).
Settings shape is split by scope: project-level secrets settings are limited to `ProjectSettings.secretsEnv`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry the plaintext passphrase.
### Test locations
The settings contract (`SecretsEnvSettings` shape, defaults, project round-trip) is covered in `@fusion/core`:
- `packages/core/src/__tests__/secrets-env.test.ts` — type contract + defaults
- `packages/core/src/__tests__/store-settings.test.ts``secretsEnv` + `secretsSyncPassphrase` project round-trip
- `packages/core/src/__tests__/store-settings.test.ts``secretsEnv` project round-trip
- `packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts` — read-only `secretsSyncPassphraseConfigured` derivation + write-strip behavior
The materialization implementation lives in `@fusion/engine` and is covered there:

View File

@@ -87,6 +87,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. |
| `settingsSyncConflictResolution` | `"last-write-wins" \| "always-ask" \| "keep-local" \| "keep-remote"` | `"last-write-wins"` | Conflict strategy for divergent synced settings. |
| `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Global default secret access policy used when a secret row does not set `access_policy`; resolver fallback remains `"prompt"`. |
| `secretsSyncPassphraseConfigured` | `boolean` | `false` | Read-only global probe for cross-node secrets-sync passphrase presence. Derived from `hasSyncPassphraseConfigured(secretsStore)` against the reserved `__sync_passphrase__` row in `secrets_global`. Not writable through settings APIs and never includes plaintext. |
| `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Global fallback policy for tasks whose owning checkout node is unavailable. Project-level `owningNodeHandoffPolicy` overrides this. |
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
@@ -193,7 +194,6 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). |
| `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). |
| `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). |
| `secretsSyncPassphrase` | `string` | `undefined` | Per-project shared passphrase for cross-node secrets sync envelope. Stored locally encrypted under the master key (reserved `__sync_passphrase__` row in `secrets_global`, `access_policy="deny"`) and never transmitted; only the derived envelope crosses the wire. See [Secrets](./secrets.md#cross-node-sync). |
| `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. |
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { createTestProject } from "./test-project.js";
import { clearSyncPassphrase, setSyncPassphrase } from "../secrets-sync-passphrase.js";
import { CentralCore } from "../central-core.js";
import { MasterKeyManager } from "../master-key.js";
import { SecretsStore } from "../secrets-store.js";
async function createSecretsStore(fixture: Awaited<ReturnType<typeof createTestProject>>): Promise<SecretsStore> {
const central = new CentralCore(fixture.globalDir);
await central.init();
const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db;
if (!centralDb) throw new Error("central db unavailable");
const masterKeyManager = new MasterKeyManager({ globalDir: fixture.globalDir });
return new SecretsStore(fixture.store.getDatabase(), centralDb, () => masterKeyManager.getOrCreateKey());
}
describe("TaskStore secretsSyncPassphraseConfigured probe", () => {
it("returns false when reserved passphrase row is absent", async () => {
const fixture = await createTestProject();
try {
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false);
expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(false);
} finally {
await fixture.cleanup();
}
});
it("flips false -> true -> false as passphrase is set and cleared", async () => {
const fixture = await createTestProject();
try {
const secrets = await createSecretsStore(fixture);
const spy = vi.spyOn(fixture.store, "getSecretsStore").mockResolvedValue(secrets);
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false);
await setSyncPassphrase(secrets, "pp");
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(true);
expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(true);
await clearSyncPassphrase(secrets);
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false);
expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(false);
spy.mockRestore();
} finally {
await fixture.cleanup();
}
});
it("exposes probe under global scope only", async () => {
const fixture = await createTestProject();
try {
const byScope = await fixture.store.getSettingsByScope();
const byScopeFast = await fixture.store.getSettingsByScopeFast();
expect(byScope.global.secretsSyncPassphraseConfigured).toBe(false);
expect(byScopeFast.global.secretsSyncPassphraseConfigured).toBe(false);
expect(byScope.project).not.toHaveProperty("secretsSyncPassphraseConfigured");
expect(byScopeFast.project).not.toHaveProperty("secretsSyncPassphraseConfigured");
} finally {
await fixture.cleanup();
}
});
it("does not persist writable overrides from updateSettings", async () => {
const fixture = await createTestProject();
try {
await fixture.store.updateSettings({ secretsSyncPassphraseConfigured: true });
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false);
await fixture.store.updateGlobalSettings({ secretsSyncPassphraseConfigured: true });
expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false);
} finally {
await fixture.cleanup();
}
});
it("falls back to false when secrets store lookup throws", async () => {
const fixture = await createTestProject();
try {
const spy = vi.spyOn(fixture.store, "getSecretsStore").mockRejectedValueOnce(new Error("boom"));
await expect(fixture.store.getSettings()).resolves.toMatchObject({ secretsSyncPassphraseConfigured: false });
spy.mockRestore();
} finally {
await fixture.cleanup();
}
});
});

View File

@@ -53,7 +53,7 @@ describe("TaskStore", () => {
});
describe("secrets integration settings", () => {
it("round-trips secretsEnv and secretsSyncPassphrase via project settings", async () => {
it("round-trips secretsEnv via project settings", async () => {
await harness.store().updateSettings({
secretsEnv: {
enabled: true,
@@ -62,7 +62,6 @@ describe("TaskStore", () => {
keyPrefix: "FUSION_",
requireGitignored: true,
},
secretsSyncPassphrase: "encrypted:abc123",
});
const settings = await harness.store().getSettings();
@@ -73,11 +72,9 @@ describe("TaskStore", () => {
keyPrefix: "FUSION_",
requireGitignored: true,
});
expect(settings.secretsSyncPassphrase).toBe("encrypted:abc123");
const { project } = await harness.store().getSettingsByScope();
expect(project.secretsEnv?.enabled).toBe(true);
expect(project.secretsSyncPassphrase).toBe("encrypted:abc123");
});
});

View File

@@ -100,6 +100,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
persistAgentThinkingLog: false,
agentMemoryInclusionMode: "full",
secretsAccessPolicy: undefined,
secretsSyncPassphraseConfigured: false,
researchGlobalDefaults: {
searchProvider: undefined,
synthesisProvider: undefined,
@@ -200,7 +201,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
owningNodeHandoffPolicy: "reassign-to-local",
defaultNodeId: undefined,
secretsEnv: undefined,
secretsSyncPassphrase: undefined,
worktreeInitCommand: undefined,
testCommand: undefined,
buildCommand: undefined,

View File

@@ -25,6 +25,7 @@ import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { SecretsStore } from "./secrets-store.js";
import { MasterKeyManager } from "./master-key.js";
import { hasSyncPassphraseConfigured } from "./secrets-sync-passphrase.js";
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
import { getInReviewStallReason } from "./in-review-stall.js";
import { getStalePausedReviewSignal } from "./stale-paused-review.js";
@@ -2343,6 +2344,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
(projectSettings as Partial<Settings>).worktrunk,
),
};
try {
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
} catch {
merged.secretsSyncPassphraseConfigured = false;
}
return canonicalizeSettings(merged);
}
@@ -2382,6 +2388,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
...projectSettings,
worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk),
};
try {
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
} catch {
merged.secretsSyncPassphraseConfigured = false;
}
return canonicalizeSettings(merged);
}
@@ -2398,6 +2409,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
try {
globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
} catch {
globalSettings.secretsSyncPassphraseConfigured = false;
}
// Extract only project-level keys from config.settings
const projectSettings: Partial<ProjectSettings> = {};
@@ -2435,6 +2451,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.globalSettingsStore.getSettings(),
this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined,
]);
try {
globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
} catch {
globalSettings.secretsSyncPassphraseConfigured = false;
}
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
@@ -2567,6 +2588,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
const globalPatch: Partial<GlobalSettings> = { ...patch };
delete globalPatch.secretsSyncPassphraseConfigured;
// Handle deep merge + targeted null clear semantics for remoteAccess
const incomingRemoteAccess = (globalPatch as Record<string, unknown>)["remoteAccess"];
@@ -2609,6 +2631,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch);
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
try {
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await this.getSecretsStore());
} catch {
merged.secretsSyncPassphraseConfigured = false;
}
// Emit settings:updated so SSE listeners pick up the change
this.emit("settings:updated", { settings: merged, previous });

View File

@@ -2150,6 +2150,11 @@ export interface GlobalSettings {
* "prompt" (route through approvals), or "deny" (reject without prompt).
* Default when unset: "prompt". */
secretsAccessPolicy?: SecretAccessPolicy;
/** Read-only derived probe for cross-node secrets sync passphrase state.
* Mirrors `hasSyncPassphraseConfigured(secretsStore)` against the reserved
* `__sync_passphrase__` row in `secrets_global`. Never includes plaintext and
* cannot be persisted via `updateSettings` / `updateGlobalSettings`. */
secretsSyncPassphraseConfigured?: boolean;
/** Policy for recovering tasks whose existing owning node becomes unavailable. */
owningNodeHandoffPolicy?: OwningNodeHandoffPolicy;
/** How long a task must remain in `status='failed'` before a push notification fires.
@@ -2624,16 +2629,6 @@ export interface ProjectSettings {
researchSettings?: ResearchProjectSettings;
/** Optional per-project `.env` materialization settings for exportable secrets. */
secretsEnv?: SecretsEnvSettings;
/**
* Encrypted shared-passphrase blob used for cross-node secrets sync.
* The stored value MUST already be ciphertext wrapped under the local
* master key by the caller/settings writer; the settings store does not
* automatically wrap this field.
*
* This value MUST NEVER be transmitted over the network; only derived
* sync envelopes may cross the wire.
*/
secretsSyncPassphrase?: string;
/** Sandbox command-execution settings.
* When omitted, runtime behavior is preserved via native passthrough defaults. */
sandbox?: SandboxProjectSettings;