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:
5
.changeset/backup-matcher-tokenized.md
Normal file
5
.changeset/backup-matcher-tokenized.md
Normal 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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user