feat(FN-5035): merge fusion/fn-5035
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user