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:
@@ -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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user