fix(fusion): prevent nested .fusion roots and safe fn version lookup

This commit is contained in:
gsxdsm
2026-05-03 02:02:25 -07:00
parent 41bb6be0f8
commit 8ba8f63163
14 changed files with 362 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Avoid nested `.fusion/.fusion` regressions by hardening project-root path handling and stop the CLI binary status probe from executing outdated global `fn` installs just to read their version.

View File

@@ -85,6 +85,7 @@ function makeMockStore() {
updatePrInfo: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: mockGlobalSettingsGetSettings,

View File

@@ -72,6 +72,7 @@ const mocks = vi.hoisted(() => {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getRootDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}`),
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: vi.fn().mockResolvedValue({}),
@@ -879,12 +880,12 @@ describe("runServe — Plugin wiring", () => {
await triggerSignal("SIGINT");
});
it("initializes PluginStore with the task store's fusion directory", async () => {
it("initializes PluginStore with the task store's project root", async () => {
const { PluginStore } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
expect(PluginStore).toHaveBeenCalledWith("/repo");
await triggerSignal("SIGINT");
});

View File

@@ -1,5 +1,5 @@
import type { AddressInfo } from "node:net";
import { join, resolve as pathResolve } from "node:path";
import { dirname, join, resolve as pathResolve } from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import { stat, readdir, readFile as fsReadFile } from "node:fs/promises";
@@ -1062,7 +1062,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
: dirname(store.getFusionDir());
const pluginStore = new PluginStore(pluginStoreRootDir);
await pluginStore.init();

View File

@@ -10,7 +10,7 @@
*/
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { dirname, join } from "node:path";
import {
CentralCore,
PluginStore,
@@ -413,7 +413,7 @@ export async function runServe(
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
: dirname(store.getFusionDir());
const pluginStore = new PluginStore(pluginStoreRootDir);
await pluginStore.init();

View File

@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.unmock("node:child_process");
vi.unmock("node:fs");
vi.unmock("node:os");
});
function createSpawnMock(options: {
lookupPath: string;
lookupCommand: "which" | "where";
versionStdout?: string;
versionExitCode?: number;
}) {
return vi.fn((command: string, args: string[]) => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
queueMicrotask(() => {
if (command === options.lookupCommand) {
child.stdout.emit("data", Buffer.from(`${options.lookupPath}\n`));
child.emit("close", 0);
return;
}
if (args.includes("--version")) {
if (options.versionStdout) {
child.stdout.emit("data", Buffer.from(options.versionStdout));
}
child.emit("close", options.versionExitCode ?? 0);
return;
}
child.emit("close", 1);
});
return child;
});
}
async function importWithMocks(options: {
lookupPath: string;
realPath: string;
platform?: "darwin" | "linux" | "win32";
packageJsons?: Record<string, { name: string; version: string }>;
scriptContents?: Record<string, string>;
versionStdout?: string;
versionExitCode?: number;
}) {
const lookupCommand = options.platform === "win32" ? "where" : "which";
const spawnMock = createSpawnMock({
lookupPath: options.lookupPath,
lookupCommand,
versionStdout: options.versionStdout,
versionExitCode: options.versionExitCode,
});
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
platform: () => options.platform ?? "darwin",
};
});
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync: vi.fn(() => options.realPath),
existsSync: vi.fn((path: string) => !!options.packageJsons?.[String(path)]),
readFileSync: vi.fn((path: string) => {
const manifest = options.packageJsons?.[String(path)];
if (manifest) {
return JSON.stringify(manifest);
}
const script = options.scriptContents?.[String(path)];
if (script !== undefined) {
return script;
}
throw new Error(`Unexpected readFileSync(${path})`);
}),
};
});
const mod = await import("../fn-binary.js");
return { mod, spawnMock };
}
describe("detectFnBinary", () => {
it("reads the installed version from the resolved package manifest without executing fn --version", async () => {
const lookupPath = "/opt/homebrew/bin/fn";
const realPath = "/opt/homebrew/lib/node_modules/runfusion.ai/index.js";
const packageJsonPath = "/opt/homebrew/lib/node_modules/runfusion.ai/package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath,
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.13.0",
},
},
});
const result = await mod.detectFnBinary();
expect(result).toMatchObject({
installed: true,
binary: "fn",
path: lookupPath,
version: "0.13.0",
invocation: "fn",
});
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("which", ["fn"], expect.any(Object));
});
it("resolves the installed version from an npm-generated Windows cmd shim without executing fn --version", async () => {
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.cmd";
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
platform: "win32",
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.14.2",
},
},
scriptContents: {
[lookupPath]: "\"%~dp0\\node_modules\\runfusion.ai\\index.js\" %*",
},
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.14.2");
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
});
it("resolves the installed version from an npm-generated Windows PowerShell shim without executing fn --version", async () => {
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.ps1";
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
platform: "win32",
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.14.3",
},
},
scriptContents: {
[lookupPath]: "& \"$basedir\\node_modules\\runfusion.ai\\index.js\" $args",
},
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.14.3");
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
});
it("falls back to fn --version when no package manifest can be derived from the resolved path", async () => {
const lookupPath = "/usr/local/bin/fn";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
versionStdout: "fn v0.15.0\n",
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.15.0");
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenNthCalledWith(1, "which", ["fn"], expect.any(Object));
expect(spawnMock).toHaveBeenNthCalledWith(2, "fn", ["--version"], expect.any(Object));
});
});

View File

@@ -1664,7 +1664,7 @@ describe("MissionStore", () => {
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
@@ -1674,7 +1674,7 @@ describe("MissionStore", () => {
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1694,7 +1694,7 @@ describe("MissionStore", () => {
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1726,7 +1726,7 @@ describe("MissionStore", () => {
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1747,7 +1747,7 @@ describe("MissionStore", () => {
it("emits feature:linked event", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
@@ -1782,7 +1782,7 @@ describe("MissionStore", () => {
it("throws if slice not found", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
@@ -1792,7 +1792,7 @@ describe("MissionStore", () => {
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1819,7 +1819,7 @@ describe("MissionStore", () => {
it("skips already triaged features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1841,7 +1841,7 @@ describe("MissionStore", () => {
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1862,7 +1862,7 @@ describe("MissionStore", () => {
ms: MissionStore;
}> {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const ms = ts.getMissionStore();
return { ts, ms };
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
import { PluginStore } from "../plugin-store.js";
import { AutomationStore } from "../automation-store.js";
import { RoutineStore } from "../routine-store.js";
describe("project root guards", () => {
const fusionDir = join(tmpdir(), "fusion-root-guard", ".fusion");
it.each([
["TaskStore", () => new TaskStore(fusionDir, undefined, { inMemoryDb: true })],
["PluginStore", () => new PluginStore(fusionDir, { inMemoryDb: true })],
["AutomationStore", () => new AutomationStore(fusionDir, { inMemoryDb: true })],
["RoutineStore", () => new RoutineStore(fusionDir, { inMemoryDb: true })],
])("rejects a .fusion directory for %s", (_label, createStore) => {
expect(createStore).toThrow(/expected a project root, got a \.fusion directory/i);
});
});

View File

@@ -11,6 +11,7 @@ import type {
import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
import type { ScheduleType } from "./automation.js";
import { Database, fromJson } from "./db.js";
import { assertProjectRootDir } from "./project-root-guard.js";
const CRON_TIMEZONE = "UTC";
@@ -52,6 +53,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
super();
assertProjectRootDir(rootDir, "AutomationStore");
this.inMemoryDb = options?.inMemoryDb === true;
}

View File

@@ -7,13 +7,15 @@
* 2. `fusion` — long alias name
* 3. `npx -y runfusion.ai` — zero-install fallback that always works
*
* The npm bin name on disk varies by install path and platform; the version
* is read by spawning `<bin> --version` so we report the actually-runnable
* binary, not just the first match on PATH.
* The npm bin name on disk varies by install path and platform. Prefer
* reading the installed package manifest from the resolved binary path so we
* don't execute older buggy global installs just to discover their version.
*/
import { spawn } from "node:child_process";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { platform, tmpdir } from "node:os";
import { posix, win32 } from "node:path";
interface ProbeResult {
exitCode: number | null;
@@ -70,6 +72,9 @@ export const FN_NPX_INVOCATION = `npx -y ${FN_NPM_PACKAGE}`;
/** Candidate binary names checked, in preference order. */
const CANDIDATES = ["fn", "fusion"] as const;
const FUSION_PACKAGE_NAMES = new Set(["runfusion.ai", "@runfusion/fusion"]);
type PathApi = Pick<typeof posix, "dirname" | "resolve" | "sep">;
export type FnBinaryName = (typeof CANDIDATES)[number];
@@ -102,6 +107,97 @@ async function whichBinary(name: string): Promise<string | undefined> {
return firstLine || undefined;
}
function getPathApi(pathValue: string): PathApi {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.includes("\\")
? win32
: posix;
}
function readPackageVersionFromPath(startPath: string): string | undefined {
const pathApi = getPathApi(startPath);
let dir = pathApi.dirname(startPath);
for (let i = 0; i < 8; i += 1) {
const packageJsonPath = pathApi.resolve(dir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
name?: string;
version?: string;
};
if (
typeof parsed.name === "string"
&& typeof parsed.version === "string"
&& FUSION_PACKAGE_NAMES.has(parsed.name)
) {
return parsed.version;
}
} catch {
// Ignore malformed manifests and keep walking upward.
}
}
const parent = pathApi.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return undefined;
}
function resolveShimTargets(resolvedPath: string): string[] {
const pathApi = getPathApi(resolvedPath);
const basedir = pathApi.dirname(resolvedPath);
let contents: string;
try {
contents = readFileSync(resolvedPath, "utf-8");
} catch {
return [];
}
const targets = new Set<string>();
const pattern = /([^\r\n"'`]*node_modules[\\/](?:runfusion\.ai|@runfusion[\\/](?:fusion))[^\r\n"'`]*(?:\.js|package\.json))/gi;
for (const match of contents.matchAll(pattern)) {
const raw = match[1];
if (!raw) continue;
const trimmed = raw.trim().replace(/^['"]|['"]$/g, "");
const normalized = trimmed
.replace(/^%~?dp0%?/i, "")
.replace(/^\$basedir/i, "")
.replace(/^\$PSScriptRoot/i, "")
.replace(/^[\\/]+/, "")
.replace(/[\\/]/g, pathApi.sep);
targets.add(pathApi.resolve(basedir, normalized));
}
return Array.from(targets);
}
function readVersionFromResolvedBinaryPath(resolvedPath: string): string | undefined {
const candidatePaths = new Set<string>([resolvedPath]);
try {
candidatePaths.add(realpathSync(resolvedPath));
} catch {
// Fall back to the original resolved path.
}
for (const shimTarget of resolveShimTargets(resolvedPath)) {
candidatePaths.add(shimTarget);
}
for (const candidatePath of candidatePaths) {
const version = readPackageVersionFromPath(candidatePath);
if (version) return version;
}
return undefined;
}
/**
* Best-effort version probe. Returns undefined if the binary refuses the
* flag or produces no parseable output — the caller should treat undefined
@@ -129,7 +225,7 @@ export async function detectFnBinary(): Promise<FnBinaryStatus> {
try {
const resolvedPath = await whichBinary(candidate);
if (!resolvedPath) continue;
const version = await probeVersion(candidate);
const version = readVersionFromResolvedBinaryPath(resolvedPath) ?? await probeVersion(candidate);
return {
installed: true,
binary: candidate,

View File

@@ -14,6 +14,7 @@ import type {
PluginState,
} from "./plugin-types.js";
import { validatePluginManifest } from "./plugin-types.js";
import { assertProjectRootDir } from "./project-root-guard.js";
export interface PluginStoreEvents {
"plugin:registered": [plugin: PluginInstallation];
@@ -69,6 +70,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
super();
assertProjectRootDir(rootDir, "PluginStore");
this.inMemoryDb = options?.inMemoryDb === true;
}

View File

@@ -0,0 +1,16 @@
/**
* Guard helpers for store constructors that expect a project root and append
* `.fusion` internally. Passing an existing `.fusion` directory produces the
* nested `.fusion/.fusion` tree we want to fail loudly on.
*/
const FUSION_DIR_SUFFIX = /(?:^|[\\/])\.fusion(?:[\\/])?$/;
export function assertProjectRootDir(rootDir: string, caller: string): void {
if (FUSION_DIR_SUFFIX.test(rootDir)) {
throw new Error(
`[fusion] ${caller} expected a project root, got a .fusion directory: ${rootDir}\n` +
"Pass the project root instead; this store appends `.fusion` internally.",
);
}
}

View File

@@ -25,6 +25,7 @@ import {
type RoutineManualTrigger,
MAX_ROUTINE_RUN_HISTORY,
} from "./routine.js";
import { assertProjectRootDir } from "./project-root-guard.js";
const CRON_TIMEZONE = "UTC";
@@ -71,6 +72,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
super();
assertProjectRootDir(rootDir, "RoutineStore");
this.inMemoryDb = options?.inMemoryDb === true;
}

View File

@@ -24,6 +24,7 @@ import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle } from "./ai-summarize.js";
import { assertProjectRootDir } from "./project-root-guard.js";
/** Database row shape for the tasks table (all columns). */
interface TaskRow {
@@ -523,6 +524,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
) {
super();
this.setMaxListeners(100);
assertProjectRootDir(rootDir, "TaskStore");
this.fusionDir = join(rootDir, ".fusion");
this.tasksDir = join(this.fusionDir, "tasks");
this.configPath = join(this.fusionDir, "config.json");