fix(FN-XXXX): narrow backup matcher and isolate version probes
Two follow-up corrections to the in-process auto-backup interception: - The matcher previously hijacked any `fn backup …` / `fusion backup …` / `runfusion.ai backup …` form. The in-process replacement only knows how to do `--create` + cleanup, so scheduling `--list`, `--cleanup`, or `--restore <file>` would have silently executed a create instead of the requested operation. The matcher is now anchored to `backup --create` (with optional trailing flags), with positive/negative unit tests. - Step-based automations (`AutomationStep` with `type: "command"`) also shell out — the legacy-command interception alone left that path vulnerable. `executeCommandStep` now applies the same in-process backup detour, factored through a shared `runBackupActionInProcess` helper. Independently, `runProbe` in fn-binary now spawns with `cwd: tmpdir()`. The dashboard's `/system/fn-binary/status` route runs `<bin> --version` on whatever fusion binary happens to be on PATH — older releases (e.g. v0.13.0) initialise an engine and create a fresh `.fusion/<project>/ .fusion/` tree as a side effect. Pinning the probe's cwd to the OS temp directory keeps any such artefacts off the developer's project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/narrow-backup-matcher.md
Normal file
5
.changeset/narrow-backup-matcher.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Tighten the in-process backup matcher to the `backup --create` form only and run `fn`/`fusion`/`runfusion.ai` `--version` probes from a temp directory. Previously any subcommand starting with `fn backup` (e.g. `--list`, `--cleanup`, `--restore`) was intercepted by the in-process runner that only knows how to create backups, so a scheduled list/cleanup/restore would silently execute a create instead. The interception now also applies to step-based automations, not just the legacy single-command form. The `--version` probe used by the dashboard fn-binary status route now spawns with `cwd=tmpdir()` so an outdated globally-installed CLI cannot drop a stray `.fusion/.fusion/` tree in the parent project's directory while the probe is running.
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { platform } from "node:os";
|
import { platform, tmpdir } from "node:os";
|
||||||
|
|
||||||
interface ProbeResult {
|
interface ProbeResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
@@ -31,7 +31,15 @@ function runProbe(command: string, args: string[], timeoutMs: number): Promise<P
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
|
// Run probes from the OS temp directory so a buggy CLI version (older
|
||||||
|
// `runfusion.ai` releases initialise an engine — and a fresh
|
||||||
|
// `.fusion/<project>/.fusion/` tree — even on `--version`) cannot leave
|
||||||
|
// artefacts under whichever project happens to be the parent's cwd.
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
shell: false,
|
||||||
|
cwd: tmpdir(),
|
||||||
|
});
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { CronRunner, createAiPromptExecutor } from "../cron-runner.js";
|
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand } from "../cron-runner.js";
|
||||||
import type { AiPromptExecutor } from "../cron-runner.js";
|
import type { AiPromptExecutor } from "../cron-runner.js";
|
||||||
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -1778,4 +1778,47 @@ describe("CronRunner", () => {
|
|||||||
expect(calls[1][0]).toBe("project-boundary");
|
expect(calls[1][0]).toBe("project-boundary");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isInProcessBackupCommand", () => {
|
||||||
|
const positives = [
|
||||||
|
"fn backup --create",
|
||||||
|
"fusion backup --create",
|
||||||
|
"runfusion.ai backup --create",
|
||||||
|
"runfusion backup --create",
|
||||||
|
"@runfusion/fusion backup --create",
|
||||||
|
"npx runfusion.ai backup --create",
|
||||||
|
"npx @runfusion/fusion backup --create",
|
||||||
|
"FN BACKUP --CREATE",
|
||||||
|
"fn backup --create --some-other-flag",
|
||||||
|
" fn backup --create ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const negatives = [
|
||||||
|
// Wrong subcommand — must not be intercepted, the in-process path
|
||||||
|
// only does create+cleanup and would silently swallow these.
|
||||||
|
"fn backup --list",
|
||||||
|
"fn backup --restore /tmp/old.db",
|
||||||
|
"fn backup --cleanup",
|
||||||
|
"fn backup",
|
||||||
|
"fn task list",
|
||||||
|
"echo hello",
|
||||||
|
"fn-other backup --create",
|
||||||
|
"fnbackup --create",
|
||||||
|
"fnext backup --create",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cmd of positives) {
|
||||||
|
it(`intercepts: ${cmd}`, () => {
|
||||||
|
expect(isInProcessBackupCommand(cmd)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cmd of negatives) {
|
||||||
|
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
||||||
|
expect(isInProcessBackupCommand(cmd)).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,19 +35,31 @@ function execCommand(command: string, options: Parameters<typeof exec>[1]): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recognize commands that the auto-backup feature schedules. These shell out
|
* Recognize commands that the auto-backup feature schedules — specifically
|
||||||
* to whatever fusion binary is on PATH — which may be older than the running
|
* the `backup --create` form that the engine itself writes via
|
||||||
* process and still carry the pluginStore-rootDir bug that creates a stray
|
* `syncBackupAutomation`. Other backup subcommands (`--list`, `--cleanup`,
|
||||||
* `.fusion/.fusion/` directory. We intercept and run the backup in-process.
|
* `--restore <file>`) are intentionally NOT intercepted because the
|
||||||
|
* in-process replacement only performs a create+cleanup; intercepting them
|
||||||
|
* would silently execute the wrong operation.
|
||||||
|
*
|
||||||
|
* These commands shell out to whatever fusion binary is on PATH, which may
|
||||||
|
* be older than the running process and still carry the pluginStore-rootDir
|
||||||
|
* bug that creates a stray `.fusion/.fusion/` directory. Intercepting the
|
||||||
|
* `--create` form keeps the auto-backup self-contained inside the running
|
||||||
|
* engine.
|
||||||
*/
|
*/
|
||||||
export function isInProcessBackupCommand(command: string | undefined): boolean {
|
export function isInProcessBackupCommand(command: string | undefined): boolean {
|
||||||
if (!command) return false;
|
if (!command) return false;
|
||||||
const normalized = command.trim().toLowerCase();
|
const normalized = command.trim().toLowerCase();
|
||||||
|
// Allow the binary name + the `backup --create` subcommand, optionally
|
||||||
|
// followed by additional whitespace-separated flags. Reject any other
|
||||||
|
// backup subcommand (--list, --cleanup, --restore, etc.).
|
||||||
|
const tail = /\s+backup\s+--create(?:\s+.*)?$/;
|
||||||
return (
|
return (
|
||||||
/^(?:npx\s+)?runfusion(?:\.ai)?\s+backup\b/.test(normalized) ||
|
new RegExp(`^(?:npx\\s+)?runfusion(?:\\.ai)?${tail.source}`).test(normalized) ||
|
||||||
/^(?:npx\s+)?@runfusion\/fusion\s+backup\b/.test(normalized) ||
|
new RegExp(`^(?:npx\\s+)?@runfusion\\/fusion${tail.source}`).test(normalized) ||
|
||||||
/^fn\s+backup\b/.test(normalized) ||
|
new RegExp(`^fn${tail.source}`).test(normalized) ||
|
||||||
/^fusion\s+backup\b/.test(normalized)
|
new RegExp(`^fusion${tail.source}`).test(normalized)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,35 +373,44 @@ export class CronRunner {
|
|||||||
schedule: ScheduledTask,
|
schedule: ScheduledTask,
|
||||||
startedAt: string,
|
startedAt: string,
|
||||||
): Promise<AutomationRunResult> {
|
): Promise<AutomationRunResult> {
|
||||||
|
const action = await this.runBackupActionInProcess();
|
||||||
|
if (action.success) {
|
||||||
|
log.log(`✓ ${schedule.name} completed in-process`);
|
||||||
|
} else {
|
||||||
|
log.warn(`✗ ${schedule.name} in-process backup ${action.error ? `threw: ${action.error}` : `reported failure: ${action.output}`}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: action.success,
|
||||||
|
output: action.output,
|
||||||
|
error: action.error,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared in-process backup execution used by both the legacy-command path
|
||||||
|
* and the command-step path. Returns the success/output/error tuple in
|
||||||
|
* a shape that callers can wrap into either a run or a step result.
|
||||||
|
*/
|
||||||
|
private async runBackupActionInProcess(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
error: string | undefined;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const { runBackupCommand } = await import("@fusion/core");
|
const { runBackupCommand } = await import("@fusion/core");
|
||||||
const fusionDir = this.store.getFusionDir();
|
const fusionDir = this.store.getFusionDir();
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
const result = await runBackupCommand(fusionDir, settings);
|
const result = await runBackupCommand(fusionDir, settings);
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
log.log(`✓ ${schedule.name} completed in-process`);
|
|
||||||
} else {
|
|
||||||
log.warn(`✗ ${schedule.name} in-process backup reported failure: ${result.output}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
output: truncateOutput(result.output ?? "", ""),
|
output: truncateOutput(result.output ?? "", ""),
|
||||||
error: result.success ? undefined : result.output,
|
error: result.success ? undefined : result.output,
|
||||||
startedAt,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
log.warn(`✗ ${schedule.name} in-process backup threw: ${message}`);
|
return { success: false, output: "", error: message };
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
output: "",
|
|
||||||
error: message,
|
|
||||||
startedAt,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +532,23 @@ export class CronRunner {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-based automations can also carry the auto-backup command. Mirror
|
||||||
|
// the legacy-command interception so step-form schedules don't fall back
|
||||||
|
// to spawning a stale `runfusion.ai` binary.
|
||||||
|
if (isInProcessBackupCommand(step.command)) {
|
||||||
|
const action = await this.runBackupActionInProcess();
|
||||||
|
return {
|
||||||
|
stepId: step.id,
|
||||||
|
stepName: step.name,
|
||||||
|
stepIndex,
|
||||||
|
success: action.success,
|
||||||
|
output: action.output,
|
||||||
|
error: action.error,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execCommand(step.command, {
|
const { stdout, stderr } = await execCommand(step.command, {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user