From c20c4b729403469aae8f3605f21a6fcdaa5893f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:56:27 -0700 Subject: [PATCH 1/3] fix: stop global settings resetting via project-local central DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production code constructed `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/`. `resolveGlobalDir()` returns an explicit dir verbatim, so this spawned stray per-project `fusion-central.db` files seeded with default global settings (globalMaxConcurrent=4, empty secrets) that shadowed the real global DB whenever a read/write hit one of those paths — surfacing as intermittent "all my global settings reset". - Add TaskStore.getGlobalSettingsDir() (resolved global dir; undefined→~/.fusion) - Route the secrets store + secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it instead of getFusionDir() - Add a resolveGlobalDir() guard that throws on a project-local `.fusion/` dir (basename `.fusion` with a `.git` parent); inert under VITEST - Regression tests in global-settings-guard.test.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lobal-settings-reset-project-central-db.md | 7 +++ .../__tests__/global-settings-guard.test.ts | 59 ++++++++++++++++++- packages/core/src/global-settings.ts | 25 +++++++- packages/core/src/store.ts | 11 +++- packages/dashboard/src/routes.ts | 2 +- .../src/routes/register-proxy-routes.ts | 6 +- .../register-secrets-sync-inbound-routes.ts | 4 +- .../routes/register-secrets-sync-routes.ts | 4 +- .../register-settings-sync-inbound-routes.ts | 7 ++- 9 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-global-settings-reset-project-central-db.md diff --git a/.changeset/fix-global-settings-reset-project-central-db.md b/.changeset/fix-global-settings-reset-project-central-db.md new file mode 100644 index 0000000000..795e6a23f0 --- /dev/null +++ b/.changeset/fix-global-settings-reset-project-central-db.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix global settings (including the global concurrency cap) intermittently resetting to defaults. +category: fix +dev: Several production call sites built `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/` and spawning stray per-project central DBs seeded with default global settings that shadowed real global state. Added `TaskStore.getGlobalSettingsDir()`, routed the secrets store plus the secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it, and added a `resolveGlobalDir()` guard that throws on a project-local `.fusion/` dir (parent is a git repo) so the regression can't silently recur. Existing stray DBs were operator-quarantined. diff --git a/packages/core/src/__tests__/global-settings-guard.test.ts b/packages/core/src/__tests__/global-settings-guard.test.ts index 10610d3699..6f0f7f884d 100644 --- a/packages/core/src/__tests__/global-settings-guard.test.ts +++ b/packages/core/src/__tests__/global-settings-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveGlobalDir } from "../global-settings.js"; @@ -68,3 +68,60 @@ describe("resolveGlobalDir() VITEST guard", () => { }); }); }); + +/* +FNXC:GlobalDirGuard 2026-06-25-22:30: +Regression for the "all my global settings reset" bug: production code that passed a project's `.fusion/` dir (e.g. store.getFusionDir()) to CentralCore/global stores spun up stray per-project central DBs seeded with default global settings that shadowed ~/.fusion. resolveGlobalDir() must refuse a project-local `.fusion/` dir (named `.fusion` inside a git repo) while still accepting the real home global dir and arbitrary non-repo custom dirs. Guard is intentionally inert under VITEST, so these tests clear VITEST to exercise it. +*/ +describe("resolveGlobalDir() project-local .fusion guard", () => { + it("throws when handed a project-local .fusion dir inside a git repo", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const projectRoot = join(homeDir, "code", "my-project"); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + const projectFusionDir = join(projectRoot, ".fusion"); + mkdirSync(projectFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(projectFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("also catches a git-worktree project (.git file, not dir)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const worktreeRoot = join(homeDir, "worktrees", "feature"); + mkdirSync(worktreeRoot, { recursive: true }); + writeFileSync(join(worktreeRoot, ".git"), "gitdir: /somewhere/.git/worktrees/feature\n"); + const worktreeFusionDir = join(worktreeRoot, ".fusion"); + mkdirSync(worktreeFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(worktreeFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("allows the real home global dir", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const homeGlobal = join(homeDir, ".fusion"); + expect(resolveGlobalDir(homeGlobal)).toBe(homeGlobal); + }); + }); + }); + + it("allows a custom non-repo global dir (no .git parent)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const customDir = join(homeDir, "custom-global", ".fusion"); + mkdirSync(customDir, { recursive: true }); + // Parent has no `.git`, so it is not a project worktree. + expect(resolveGlobalDir(customDir)).toBe(customDir); + }); + }); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 02a1474c14..877408d7d4 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -14,7 +14,7 @@ */ import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; import { existsSync, mkdirSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; @@ -90,7 +90,28 @@ export function resolveGlobalDir(dir?: string): string { ); } - if (hasExplicitDir) return dir; + if (hasExplicitDir) { + /* + FNXC:GlobalDirGuard 2026-06-25-22:10: + Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. + Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + */ + if (process.env.VITEST !== "true") { + const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); + const looksLikeProjectFusionDir = + dir !== homeGlobalDir && + basename(dir) === ".fusion" && + existsSync(join(dirname(dir), ".git")); + if (looksLikeProjectFusionDir) { + throw new Error( + `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir().", + ); + } + } + return dir; + } return resolveGlobalDirForHome(getHomeDir()); } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9b4eeb08fd..2cc55e1299 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16651,6 +16651,14 @@ ${stepsSection}`; return this.fusionDir; } + /* + FNXC:GlobalDirGuard 2026-06-25-22:12: + The resolved GLOBAL settings dir (undefined → ~/.fusion). Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + */ + getGlobalSettingsDir(): string | undefined { + return this.globalSettingsDir; + } + getTasksDir(): string { return this.tasksDir; } @@ -16673,7 +16681,8 @@ ${stepsSection}`; return this.secretsStore; } - const central = new CentralCore(this.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:13: Secrets live in the GLOBAL central DB (~/.fusion), not this project's `.fusion/`. Use the resolved global dir; passing getFusionDir() created a stray per-project central DB and reset global settings. + const central = new CentralCore(this.getGlobalSettingsDir()); await central.init(); this.secretsCentralCore = central; const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a6cff4824b..9f84c03189 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,7 +4463,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Node-aware proxying: route to remote node if nodeId is provided and not local if (nodeId) { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); const localNodes = await central.listNodes(); diff --git a/packages/dashboard/src/routes/register-proxy-routes.ts b/packages/dashboard/src/routes/register-proxy-routes.ts index d14435afca..03a770ab87 100644 --- a/packages/dashboard/src/routes/register-proxy-routes.ts +++ b/packages/dashboard/src/routes/register-proxy-routes.ts @@ -34,7 +34,7 @@ async function proxyToRemoteNode( const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -187,7 +187,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const nodeId = req.params.nodeId as string; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -360,7 +360,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const remainingPath = Array.isArray(splat) ? splat.join("/") : splat; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); diff --git a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts index e3716146cc..ea8c69c0e8 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts @@ -92,7 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/secrets/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth @@ -174,7 +174,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/secrets/sync-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth diff --git a/packages/dashboard/src/routes/register-secrets-sync-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-routes.ts index 9d9c9ec0cd..d2ed109f8b 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-routes.ts @@ -52,7 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/push", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); @@ -110,7 +110,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/pull", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 57a287dc1d..15a021ed4e 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -74,7 +74,8 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:20: Inbound settings sync writes GLOBAL central state, so it must use the resolved global dir (~/.fusion). Previously this (and the secrets/proxy/node routes) passed store.getFusionDir() — the project `.fusion/` — which created a stray per-project central DB seeded with default global settings, the root cause of intermittent "all my global settings reset". Mirror this requirement on every CentralCore construction in dashboard routes. + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth - find local node and check apiKey @@ -181,7 +182,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/auth-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth @@ -278,7 +279,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/settings/auth-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth From 84ec10d1a7225c56e26b09b78add15ca65e10b34 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:18:56 -0700 Subject: [PATCH 2/3] fix(review): harden global-dir fix per code review Addresses code-review findings on the global-settings reset fix: - getGlobalSettingsDir() now returns a resolved `string` (was `string | undefined`), so the project-local `.fusion` guard fires at the getter call site instead of leaking CentralCore's undefined-default semantics. - getSecretsStore() passes the resolved global dir to MasterKeyManager so the master key co-locates with the global central DB and the path is exercisable under tests (a bare new MasterKeyManager() throws in VITEST). - resolveGlobalDir() guard gains an explicit FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR opt-out so a legitimately version-controlled custom global dir (dotfiles repo with a .git parent) is not hard-rejected. - Add a symptom-based regression test (store-secrets-store-global-dir) proving the secrets central DB lands in the global dir and never spawns a stray project-local fusion-central.db. - Add getGlobalSettingsDir() to route-test mock stores (CentralCore is mocked, so it mirrors getFusionDir()) and FNXC comments to the remaining route sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store-secrets-store-global-dir.test.ts | 47 +++++++++++++++++++ packages/core/src/global-settings.ts | 8 +++- packages/core/src/store.ts | 14 ++++-- .../__tests__/browse-directory-routes.test.ts | 5 ++ .../src/__tests__/proxy-routes.test.ts | 5 ++ .../routes-nodes-sync-contract.test.ts | 5 ++ .../src/__tests__/routes-nodes-sync.test.ts | 5 ++ .../src/__tests__/routes-proxy.test.ts | 5 ++ packages/dashboard/src/routes.ts | 1 + .../src/routes/register-proxy-routes.ts | 1 + .../register-secrets-sync-inbound-routes.ts | 1 + .../routes/register-secrets-sync-routes.ts | 1 + 12 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/store-secrets-store-global-dir.test.ts diff --git a/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts new file mode 100644 index 0000000000..2f6cd1c4ab --- /dev/null +++ b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "../store.js"; + +/* +FNXC:GlobalDirGuard 2026-06-25-23:05: +Symptom-based regression for the "all my global settings reset" bug. The root cause was getSecretsStore() (and dashboard routes) constructing CentralCore with `store.getFusionDir()` (the project's `.fusion/`), which created a stray per-project `fusion-central.db` seeded with default global state that shadowed the real global DB. These tests assert the INVARIANT directly: the secrets store's central DB lands in the resolved GLOBAL dir and NOT inside the project `.fusion/` dir, and that getGlobalSettingsDir() is distinct from getFusionDir(). Surface enumeration: this covers the store/secrets surface; the resolveGlobalDir guard surfaces are covered in global-settings-guard.test.ts. +*/ +describe("TaskStore.getSecretsStore() central DB location (global, not project-local)", () => { + let root: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), "fn-secrets-global-dir-")); + globalDir = join(root, ".fusion-global-settings"); + store = new TaskStore(root, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("resolves getGlobalSettingsDir() to the global dir, distinct from getFusionDir()", () => { + expect(store.getGlobalSettingsDir()).toBe(globalDir); + expect(store.getFusionDir()).toBe(join(root, ".fusion")); + expect(store.getGlobalSettingsDir()).not.toBe(store.getFusionDir()); + }); + + it("creates the secrets central DB in the global dir and never in the project .fusion/", async () => { + await store.getSecretsStore(); + + // The central DB must live in the resolved global dir... + expect(existsSync(join(globalDir, "fusion-central.db"))).toBe(true); + // ...and must NOT have spawned a stray per-project central DB (the original bug). + expect(existsSync(join(store.getFusionDir(), "fusion-central.db"))).toBe(false); + }); + + it("returns a stable singleton secrets store across calls", async () => { + const a = await store.getSecretsStore(); + const b = await store.getSecretsStore(); + expect(a).toBe(b); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 877408d7d4..e4187e09e1 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -95,8 +95,11 @@ export function resolveGlobalDir(dir?: string): string { FNXC:GlobalDirGuard 2026-06-25-22:10: Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + + FNXC:GlobalDirGuard 2026-06-25-22:55: + The heuristic is intentionally conservative but can't perfectly distinguish a project `.fusion` from a legitimately version-controlled custom global dir (e.g. a dotfiles repo with `~/dotfiles/.fusion` + `.git`). To avoid hard-crashing that rare setup, honor an explicit opt-out env var `FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true`. This is not reachable via normal production call sites (they resolve to ~/.fusion); it only matters for operators who deliberately configure a custom global dir inside a repo. */ - if (process.env.VITEST !== "true") { + if (process.env.VITEST !== "true" && process.env.FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR !== "true") { const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); const looksLikeProjectFusionDir = dir !== homeGlobalDir && @@ -106,7 +109,8 @@ export function resolveGlobalDir(dir?: string): string { throw new Error( `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + - "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir().", + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir(). " + + "If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.", ); } } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2cc55e1299..f23cc4dfa4 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -146,7 +146,7 @@ import { normalizeTaskPriority } from "./task-priority.js"; import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; -import { GlobalSettingsStore } from "./global-settings.js"; +import { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; @@ -16653,10 +16653,13 @@ ${stepsSection}`; /* FNXC:GlobalDirGuard 2026-06-25-22:12: - The resolved GLOBAL settings dir (undefined → ~/.fusion). Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + The resolved GLOBAL settings dir. Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + + FNXC:GlobalDirGuard 2026-06-25-22:50: + Returns a fully-RESOLVED absolute path (string), not the raw optional field. Resolving here (rather than leaking CentralCore's `undefined → ~/.fusion` default to every caller) makes the contract honest and fires the project-local `.fusion` guard at this call site instead of deferring it to CentralCore construction. Under VITEST `this.globalSettingsDir` is always set to a temp dir, so resolveGlobalDir returns it verbatim and never throws the no-explicit-dir test error. */ - getGlobalSettingsDir(): string | undefined { - return this.globalSettingsDir; + getGlobalSettingsDir(): string { + return resolveGlobalDir(this.globalSettingsDir); } getTasksDir(): string { @@ -16689,7 +16692,8 @@ ${stepsSection}`; if (!centralDb) { throw new Error("Central database unavailable for secrets store"); } - const masterKeyManager = new MasterKeyManager(); + // FNXC:GlobalDirGuard 2026-06-25-23:00: The master key is GLOBAL — pass the resolved global dir explicitly so it co-locates with the global central DB (matching prod ~/.fusion) and so getSecretsStore() is exercisable under tests (a bare new MasterKeyManager() throws under VITEST because resolveGlobalDir() requires an explicit dir there). + const masterKeyManager = new MasterKeyManager({ globalDir: this.getGlobalSettingsDir() }); const masterKeyProvider = () => masterKeyManager.getOrCreateKey(); this.secretsStore = new SecretsStore(this.db, centralDb, masterKeyProvider); return this.secretsStore; diff --git a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts index d74fcbe858..bc05399869 100644 --- a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts +++ b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts @@ -71,6 +71,11 @@ class MockStoreForRoutes extends EventEmitter { return "/tmp/fn-944/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/proxy-routes.test.ts b/packages/dashboard/src/__tests__/proxy-routes.test.ts index d1f9230a00..007f9b1cbf 100644 --- a/packages/dashboard/src/__tests__/proxy-routes.test.ts +++ b/packages/dashboard/src/__tests__/proxy-routes.test.ts @@ -47,6 +47,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts index a07a817e66..dd2236f683 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts @@ -88,6 +88,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-4755-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts index d38186bf1d..46e11323f1 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts @@ -104,6 +104,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-1821-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-proxy.test.ts b/packages/dashboard/src/__tests__/routes-proxy.test.ts index d54c6e28ef..7047d0512c 100644 --- a/packages/dashboard/src/__tests__/routes-proxy.test.ts +++ b/packages/dashboard/src/__tests__/routes-proxy.test.ts @@ -30,6 +30,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-1806/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 9f84c03189..0c6c7a3b96 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,6 +4463,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Node-aware proxying: route to remote node if nodeId is provided and not local if (nodeId) { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Node-aware proxy lookup uses GLOBAL central state — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); diff --git a/packages/dashboard/src/routes/register-proxy-routes.ts b/packages/dashboard/src/routes/register-proxy-routes.ts index 03a770ab87..3e1a7400eb 100644 --- a/packages/dashboard/src/routes/register-proxy-routes.ts +++ b/packages/dashboard/src/routes/register-proxy-routes.ts @@ -34,6 +34,7 @@ async function proxyToRemoteNode( const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Node proxy state is GLOBAL — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); try { diff --git a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts index ea8c69c0e8..930ab7d14f 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts @@ -92,6 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/secrets/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Inbound secrets sync writes GLOBAL central state — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { diff --git a/packages/dashboard/src/routes/register-secrets-sync-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-routes.ts index d2ed109f8b..dadb30299e 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-routes.ts @@ -52,6 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/push", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Secrets-sync node state is GLOBAL — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { From 9fd4dedcae816fb980032213e5df97f1e9bb761f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 23:17:02 -0700 Subject: [PATCH 3/3] fix(review): resolve PR feedback on resolveGlobalDir guard - Avoid the legacy-migration side effect on the hot path: run the cheap, read-only checks (basename === ".fusion" && parent has .git) FIRST, and only call resolveGlobalDirForHome() (which can perform a one-time rename) when a dir actually looks project-local. - Normalize paths before the home-dir comparison (realpathSync when present, else resolve) so a trailing slash, doubled separator, or symlinked home doesn't make the legitimate home global dir trip the guard. - Harden the browse-directory regression test: the mock returns a global dir DISTINCT from getFusionDir() and asserts CentralCore is constructed with the global dir, so a revert to getFusionDir() now fails the test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/global-settings.ts | 40 ++++++++++++------- .../__tests__/browse-directory-routes.test.ts | 7 +++- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index e4187e09e1..3054849753 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -14,9 +14,9 @@ */ import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; -import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "./types.js"; import { sanitizeCliAgentsSettings } from "./settings-schema.js"; @@ -98,20 +98,32 @@ export function resolveGlobalDir(dir?: string): string { FNXC:GlobalDirGuard 2026-06-25-22:55: The heuristic is intentionally conservative but can't perfectly distinguish a project `.fusion` from a legitimately version-controlled custom global dir (e.g. a dotfiles repo with `~/dotfiles/.fusion` + `.git`). To avoid hard-crashing that rare setup, honor an explicit opt-out env var `FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true`. This is not reachable via normal production call sites (they resolve to ~/.fusion); it only matters for operators who deliberately configure a custom global dir inside a repo. + + FNXC:GlobalDirGuard 2026-06-26-06:25: + Order matters and the home-dir comparison must be normalized: + - Run the CHEAP, read-only checks first (basename is `.fusion` AND its parent contains a `.git`). Only if both hold do we call `resolveGlobalDirForHome()` — which can perform a one-time legacy-dir rename — so we never trigger that filesystem side effect on the hot path (every explicit-dir call, e.g. getGlobalSettingsDir() in dashboard routes, previously hit it). + - Compare against the home global dir using normalized real paths (realpathSync when the path exists, else resolve()), so a trailing slash, doubled separator, or symlinked home dir doesn't make the legitimate home global dir look like a foreign project dir and trip the guard. */ if (process.env.VITEST !== "true" && process.env.FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR !== "true") { - const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); - const looksLikeProjectFusionDir = - dir !== homeGlobalDir && - basename(dir) === ".fusion" && - existsSync(join(dirname(dir), ".git")); - if (looksLikeProjectFusionDir) { - throw new Error( - `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + - "This would create a stray per-project central database seeded with default global settings and silently reset them. " + - "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir(). " + - "If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.", - ); + const isFusionDirInsideRepo = + basename(dir) === ".fusion" && existsSync(join(dirname(dir), ".git")); + if (isFusionDirInsideRepo) { + const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); + const normalize = (p: string): string => { + try { + return realpathSync.native(p); + } catch { + return resolve(p); + } + }; + if (normalize(dir) !== normalize(homeGlobalDir)) { + throw new Error( + `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir(). " + + "If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.", + ); + } } } return dir; diff --git a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts index bc05399869..1cccd30a46 100644 --- a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts +++ b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts @@ -40,6 +40,7 @@ vi.mock("@fusion/core", async () => { // Import after mocking import { browseDirectory } from "../../app/api.js"; +import { CentralCore } from "@fusion/core"; function mockFetchResponse( ok: boolean, @@ -71,9 +72,9 @@ class MockStoreForRoutes extends EventEmitter { return "/tmp/fn-944/.fusion"; } - // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + // FNXC:GlobalDirGuard 2026-06-26-06:25: Return a global dir DISTINCT from getFusionDir() so the regression test can assert the route constructs CentralCore with the global dir — a future revert to getFusionDir() then fails the CentralCore-constructor assertion below instead of silently passing. getGlobalSettingsDir(): string { - return this.getFusionDir(); + return "/tmp/fn-944/.fusion-global"; } getDatabase() { @@ -154,6 +155,8 @@ describe("GET /api/browse-directory route handler", () => { expect(mockListNodes).toHaveBeenCalled(); expect(mockClose).toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled(); + // FNXC:GlobalDirGuard 2026-06-26-06:25: The node-aware route must build CentralCore from the GLOBAL dir, never the project `.fusion/`. Asserting the distinct global path makes a revert to getFusionDir() ("/tmp/fn-944/.fusion") fail here. + expect(vi.mocked(CentralCore)).toHaveBeenCalledWith("/tmp/fn-944/.fusion-global"); }); });