fix: stop global settings resetting via project-local central DBs

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-25 21:56:27 -07:00
parent b6b5583f01
commit c20c4b7294
9 changed files with 110 additions and 15 deletions

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { resolveGlobalDir } from "../global-settings.js"; 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);
});
});
});
});

View File

@@ -14,7 +14,7 @@
*/ */
import { homedir } from "node:os"; 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 { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
import { existsSync, mkdirSync, renameSync } from "node:fs"; import { existsSync, mkdirSync, renameSync } from "node:fs";
import type { GlobalSettings } from "./types.js"; 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()); return resolveGlobalDirForHome(getHomeDir());
} }

View File

@@ -16651,6 +16651,14 @@ ${stepsSection}`;
return this.fusionDir; 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 { getTasksDir(): string {
return this.tasksDir; return this.tasksDir;
} }
@@ -16673,7 +16681,8 @@ ${stepsSection}`;
return this.secretsStore; 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(); await central.init();
this.secretsCentralCore = central; this.secretsCentralCore = central;
const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db; const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db;

View File

@@ -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 // Node-aware proxying: route to remote node if nodeId is provided and not local
if (nodeId) { if (nodeId) {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
const localNodes = await central.listNodes(); const localNodes = await central.listNodes();

View File

@@ -34,7 +34,7 @@ async function proxyToRemoteNode(
const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const timeoutMs = proxyOptions?.timeoutMs ?? 10_000;
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
try { try {
await central.init(); await central.init();
@@ -187,7 +187,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void
const nodeId = req.params.nodeId as string; const nodeId = req.params.nodeId as string;
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
try { try {
await central.init(); await central.init();
@@ -360,7 +360,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void
const remainingPath = Array.isArray(splat) ? splat.join("/") : splat; const remainingPath = Array.isArray(splat) ? splat.join("/") : splat;
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
try { try {
await central.init(); await central.init();

View File

@@ -92,7 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
router.post("/secrets/sync-receive", async (req, res) => { router.post("/secrets/sync-receive", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
try { try {
// Validate auth // Validate auth
@@ -174,7 +174,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
router.get("/secrets/sync-export", async (req, res) => { router.get("/secrets/sync-export", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
try { try {
// Validate auth // Validate auth

View File

@@ -52,7 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => {
router.post("/nodes/:id/secrets/push", async (req, res) => { router.post("/nodes/:id/secrets/push", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
try { try {
const node = await central.getNode(req.params.id); 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) => { router.post("/nodes/:id/secrets/pull", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
try { try {
const node = await central.getNode(req.params.id); const node = await central.getNode(req.params.id);

View File

@@ -74,7 +74,8 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
router.post("/settings/sync-receive", async (req, res) => { router.post("/settings/sync-receive", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); 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(); await central.init();
// Validate auth - find local node and check apiKey // 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) => { router.post("/settings/auth-receive", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
// Validate auth // Validate auth
@@ -278,7 +279,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
router.get("/settings/auth-export", async (req, res) => { router.get("/settings/auth-export", async (req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir()); const central = new CentralCore(store.getGlobalSettingsDir());
await central.init(); await central.init();
// Validate auth // Validate auth