fix(gbrain): quote cmd.exe arguments at a single gbrain invocation seam

Fixes #2471. With shell:true on Windows, node/bun join argv into one cmd.exe
string without quoting, so a repo path with a space — the default
C:\Users\First Last\ layout — split into two arguments and every gbrain call
carrying a path silently targeted the wrong location (worst: `sources add
--path`). All gbrain CLI invocations now build their (cmd, argv, shell)
triple through gbrainInvocation(), which quotes risky arguments for cmd.exe's
re-parse (embedded quotes doubled). The four direct spawn sites in
lib/gbrain-sources.ts route through the seam; the #1731 static invariant is
upgraded for seamed files (any direct "gbrain" opener is the violation) and
kept as-is for lib/gbrain-local-status.ts. POSIX behavior unchanged
(shell:false, passthrough argv).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 08:54:06 -07:00
co-authored by Claude Fable 5
parent ea780fed61
commit 4047e52bc6
3 changed files with 119 additions and 39 deletions
+64 -15
View File
@@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test";
import * as fs from "fs";
import * as path from "path";
import { bashScriptInvocation } from "../lib/gbrain-exec";
import { bashScriptInvocation, gbrainInvocation, windowsShellQuote } from "../lib/gbrain-exec";
const ROOT = path.resolve(import.meta.dir, "..");
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
@@ -18,24 +18,30 @@ describe("#1731 gbrain spawns carry the Windows shell flag", () => {
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
});
// Every direct `gbrain` child spawn in these files must be matched by a
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
// refactor-resistant invariant.
const gbrainSpawnFiles = [
"lib/gbrain-exec.ts",
"lib/gbrain-sources.ts",
"lib/gbrain-local-status.ts",
];
for (const rel of gbrainSpawnFiles) {
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
// #2471 upgraded the #1731 invariant for the seamed files: gbrain spawns
// there must build their (cmd, argv, shell) triple via gbrainInvocation()
// (which owns BOTH the shell flag and cmd.exe quoting), so a direct
// `spawn*("gbrain"` opener is itself the violation.
const seamedFiles = ["lib/gbrain-exec.ts", "lib/gbrain-sources.ts"];
for (const rel of seamedFiles) {
test(`${rel}: gbrain spawns route through gbrainInvocation (no direct openers)`, () => {
const src = read(rel);
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(spawnOpeners).toBeGreaterThan(0);
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
const directOpeners = src.match(/(spawnSync|spawn|execFileSync)\(\s*["']gbrain["']/g)?.length ?? 0;
expect(directOpeners).toBe(0);
expect(src).toContain("gbrainInvocation(");
});
}
// Not-yet-seamed file: every direct gbrain spawn must still carry the
// #1731 shell flag. (Migrate to gbrainInvocation when next touched.)
test("lib/gbrain-local-status.ts: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS", () => {
const src = read("lib/gbrain-local-status.ts");
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(spawnOpeners).toBeGreaterThan(0);
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
});
// NOT the brain-sync script. `shell: true` is right for the gbrain.cmd shim
// and wrong for a bash shebang script: cmd.exe resolves .cmd/.bat via PATHEXT
// and has no concept of a shebang, so gstack-brain-sync came back as "is not
@@ -115,3 +121,46 @@ describe("bashScriptInvocation", () => {
expect(inv).toBeNull();
});
});
// #2471: with `shell: true` on Windows, node/bun JOIN argv into one cmd.exe
// string without quoting — a path with a space (`C:\Users\First Last\repo`)
// splits into two arguments and `gbrain sources add --path` targets the wrong
// directory. The invocation seam quotes every risky argument exactly once.
describe("#2471 gbrain invocation seam quotes for cmd.exe", () => {
test("safe charset passes through untouched", () => {
expect(windowsShellQuote("sources")).toBe("sources");
expect(windowsShellQuote("--json")).toBe("--json");
expect(windowsShellQuote("C:\\Users\\j\\repo")).toBe("C:\\Users\\j\\repo");
});
test("a path with a space is double-quoted", () => {
expect(windowsShellQuote("C:\\Users\\First Last\\repo")).toBe('"C:\\Users\\First Last\\repo"');
});
test("embedded quotes are doubled (cmd.exe escape)", () => {
expect(windowsShellQuote('we"ird')).toBe('"we""ird"');
});
test("empty argument stays a quoted empty string, not vanishing", () => {
expect(windowsShellQuote("")).toBe('""');
});
test("shell metacharacters are wrapped so cmd.exe cannot interpret them", () => {
for (const bad of ["a b", "a&b", "a|b", "a>b", "a<b", "a^b", "a(b)", "a;b"]) {
expect(windowsShellQuote(bad).startsWith('"')).toBe(true);
}
});
test("gbrainInvocation on POSIX is a passthrough with shell:false", () => {
if (process.platform === "win32") return; // the win32 half is the map+quote path above
const inv = gbrainInvocation(["sources", "add", "id", "--path", "/a dir/with space"]);
expect(inv).toEqual({ cmd: "gbrain", argv: ["sources", "add", "id", "--path", "/a dir/with space"], shell: false });
});
test("no direct un-seamed gbrain spawn remains in gbrain-sources.ts", () => {
const src = read("lib/gbrain-sources.ts");
expect(src).not.toMatch(/spawnSync\(\s*["']gbrain["']/);
expect(src).not.toMatch(/execFileSync\(\s*["']gbrain["']/);
expect(src).toContain("gbrainInvocation(");
});
});