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

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