fix(FN-XXXX): tokenize backup-command matcher

Two follow-ups to the in-process backup interception:

- Previously the matcher only allowed a bare `npx` prefix, so the
  canonical zero-install form `npx -y runfusion.ai backup --create`
  (and any `npx --yes` / `-p <pkg>` / `--package=<pkg>` variant) fell
  through to the legacy shell-out path. The matcher now consumes any
  number of npx flags before the binary token so all canonical
  invocations route through the in-process executor.
- Previously the matcher accepted arbitrary text after `--create` and
  the runner silently dropped it. Authors writing
  `fn backup --create && notify-send done` or
  `fn backup --create | tee log` reasonably expected the trailing
  side effect to fire. The matcher now refuses any command containing
  shell continuations / redirections / substitutions
  (`&&`, `||`, `|`, `;`, `>`, `<`, backticks, `$()`), and rejects
  trailing positional arguments. Such commands shell out as the user
  wrote them.

The matcher is now a small tokenizer rather than a regex collection,
so the contract is easier to read and the unit-test grid covers each
permitted prefix combination plus all the previously-unhandled shell
forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 22:58:18 -07:00
parent 31195374d4
commit dd291db725
3 changed files with 108 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Tokenize `isInProcessBackupCommand` so it accepts the full canonical zero-install form `npx -y runfusion.ai backup --create` (and other npx flag combinations such as `--yes`, `-p <pkg>`, `--package=<pkg>`) and refuses commands that embed shell continuations or redirections (`&&`, `||`, `|`, `;`, `>`, `<`, backticks, `$()`). The previous regex permitted only a bare `npx` prefix and silently swallowed any tail after `--create`, which meant `npx -y runfusion.ai backup --create` still hit the legacy shell-out and `fn backup --create && notify-send done` lost its trailing side effect when intercepted. The new matcher only intercepts when the entire command is a plain in-process backup invocation; anything else continues through the shell as authored.

View File

@@ -1781,19 +1781,28 @@ describe("CronRunner", () => {
describe("isInProcessBackupCommand", () => {
const positives = [
// Bare binary forms.
"fn backup --create",
"fusion backup --create",
"runfusion.ai backup --create",
"runfusion backup --create",
"@runfusion/fusion backup --create",
// npx prefix without flags.
"npx runfusion.ai backup --create",
"npx @runfusion/fusion backup --create",
// npx with flags — including the canonical `npx -y runfusion.ai`
// form FN_NPX_INVOCATION emits.
"npx -y runfusion.ai backup --create",
"npx --yes runfusion.ai backup --create",
"npx -y -p runfusion.ai runfusion.ai backup --create",
"npx --package=runfusion.ai runfusion.ai backup --create",
// Whitespace / case tolerance.
"FN BACKUP --CREATE",
"fn backup --create --some-other-flag",
" fn backup --create ",
];
const negatives = [
const negatives: Array<string | undefined> = [
// Wrong subcommand — must not be intercepted, the in-process path
// only does create+cleanup and would silently swallow these.
"fn backup --list",
@@ -1807,6 +1816,18 @@ describe("CronRunner", () => {
"fnext backup --create",
"",
undefined,
// Shell continuations after --create encode user-authored side
// effects we cannot mirror in-process; let them shell out.
"fn backup --create && notify-send done",
"fn backup --create || echo failed",
"fn backup --create | tee /tmp/log",
"fn backup --create ; rm -rf /tmp/garbage",
"fn backup --create > /tmp/out.txt",
"fn backup --create 2> /tmp/err.txt",
"fn backup --create `whoami`",
"fn backup --create $(date)",
// Trailing positional arguments aren't `--flag`s — refuse.
"fn backup --create extra-positional",
];
for (const cmd of positives) {

View File

@@ -42,25 +42,90 @@ function execCommand(command: string, options: Parameters<typeof exec>[1]): Prom
* 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.
* The matcher tokenizes the command so it can:
* - accept any of the canonical invocations (`fn`, `fusion`,
* `runfusion`/`runfusion.ai`, `@runfusion/fusion`), with or without an
* `npx` prefix and any combination of npx flags (e.g. `-y`, `--yes`,
* `-p <pkg>`), so `npx -y runfusion.ai backup --create` is covered;
* - reject commands carrying shell continuations after `--create`
* (`&&`, `||`, `|`, `;`, `>`, `<`, backticks, `$()`, etc.) — these
* embed user-authored side effects that the in-process path cannot
* replicate, so we let them shell out to keep the side effects intact.
*
* Commands that shell out instead reach 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 canonical `--create` form keeps the
* auto-backup self-contained inside the running engine.
*/
const FUSION_BINARY_TOKENS = new Set([
"fn",
"fusion",
"runfusion",
"runfusion.ai",
"@runfusion/fusion",
]);
/**
* Characters that introduce shell continuations, redirections, or
* substitutions. Their presence anywhere in the command means the
* scheduler author wired in additional side effects we cannot honour
* by simply running the in-process backup, so we decline interception.
*/
const SHELL_METACHARACTERS_REGEX = /[&|;<>`$()]/;
export function isInProcessBackupCommand(command: string | undefined): boolean {
if (!command) return false;
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 (
new RegExp(`^(?:npx\\s+)?runfusion(?:\\.ai)?${tail.source}`).test(normalized) ||
new RegExp(`^(?:npx\\s+)?@runfusion\\/fusion${tail.source}`).test(normalized) ||
new RegExp(`^fn${tail.source}`).test(normalized) ||
new RegExp(`^fusion${tail.source}`).test(normalized)
);
const trimmed = command.trim();
if (!trimmed) return false;
// Refuse any command that embeds a shell continuation / redirection /
// substitution. These tokens carry intent we cannot mirror in-process.
if (SHELL_METACHARACTERS_REGEX.test(trimmed)) return false;
const tokens = trimmed.split(/\s+/).map((tok) => tok.toLowerCase());
let cursor = 0;
// Optional `npx` prefix, with any number of npx flags (e.g. `-y`,
// `--yes`, `-p <pkg>`, `--package=<pkg>`). Stop consuming once we hit
// the package name token.
if (tokens[cursor] === "npx") {
cursor += 1;
while (cursor < tokens.length) {
const tok = tokens[cursor];
if (tok === undefined || !tok.startsWith("-")) break;
// `-p <pkg>` consumes the next token as a value; same for the
// rare `--package <pkg>` form. The `--package=<pkg>` and `-y`
// forms don't take a separate argument.
const takesValue = (tok === "-p" || tok === "--package")
&& cursor + 1 < tokens.length
&& tokens[cursor + 1] !== undefined
&& !tokens[cursor + 1]!.startsWith("-");
cursor += takesValue ? 2 : 1;
}
}
// Required: a fusion binary token.
const binary = tokens[cursor];
if (!binary || !FUSION_BINARY_TOKENS.has(binary)) return false;
cursor += 1;
// Required: literal `backup` then `--create`.
if (tokens[cursor] !== "backup") return false;
cursor += 1;
if (tokens[cursor] !== "--create") return false;
cursor += 1;
// Anything left over must look like additional `--flag` style options.
// Reject bare argument tokens — they suggest a different subcommand or
// user-authored payload we should not silently swallow.
for (; cursor < tokens.length; cursor += 1) {
const tok = tokens[cursor];
if (!tok) continue;
if (!tok.startsWith("-")) return false;
}
return true;
}
/** Default execution timeout: 5 minutes. */