From fb3103237a40fe43fc8c9f76907ac42276400e96 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 30 May 2026 10:42:15 -0700 Subject: [PATCH] fix(gbrain): spawn gbrain + brain-sync through a shell on Windows (#1731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, bun/npm install gbrain as a gbrain.cmd/.ps1 shim and gstack-brain-sync is a bash shebang script. spawnSync/spawn/execFileSync resolve neither without a shell, so the child spawn failed ENOENT — on the sync orchestrator this surfaced as 'brain-sync exited undefined' (#1731). Add NEEDS_SHELL_ON_WINDOWS (process.platform === 'win32') in gbrain-exec and pass it as shell: to every gbrain/brain-sync child spawn: spawnGbrain, spawnGbrainAsync, execGbrainText (gbrain-exec), the two sources-list/remove/add spawns (gbrain-sources), the version + probe spawns (gbrain-local-status), and the two brain-sync spawns in the orchestrator. POSIX keeps the cheaper no-shell path. macOS/Linux CI can't exercise the Windows path, so test/gbrain-spawn-windows-shell.ts is a static-grep tripwire: it fails CI if a gbrain/brain-sync spawn is added without the shell flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/gstack-gbrain-sync.ts | 6 +++- lib/gbrain-exec.ts | 15 +++++++++ lib/gbrain-local-status.ts | 5 ++- lib/gbrain-sources.ts | 5 +++ test/gbrain-spawn-windows-shell.test.ts | 45 +++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 test/gbrain-spawn-windows-shell.test.ts diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index f84e6b2ab..2515a763c 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -39,7 +39,7 @@ import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; import { ensureSourceRegistered, sourcePageCount, parseSourcesList } from "../lib/gbrain-sources"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; -import { buildGbrainEnv, spawnGbrain, execGbrainJson } from "../lib/gbrain-exec"; +import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; // ── Types ────────────────────────────────────────────────────────────────── @@ -958,13 +958,17 @@ function runBrainSyncPush(args: CliArgs): StageResult { return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; } + // #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it + // without a shell, which surfaced as "brain-sync exited undefined". spawnSync(brainSyncPath, ["--discover-new"], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: 60 * 1000, + shell: NEEDS_SHELL_ON_WINDOWS, }); const result = spawnSync(brainSyncPath, ["--once"], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: 60 * 1000, + shell: NEEDS_SHELL_ON_WINDOWS, }); return { diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 4568ef41a..12855d11d 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -137,6 +137,18 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process return out; } +/** + * Windows can't directly spawn the `gbrain` launcher (bun/npm install it as a + * `gbrain.cmd`/`.ps1` shim) or a shebang script like the bash `gstack-brain-sync` + * — `spawnSync`/`spawn` resolve those only through a shell's PATHEXT + interpreter + * lookup. Without `shell: true` the child spawn fails ENOENT, which on the sync + * orchestrator surfaced as "brain-sync exited undefined" (#1731). Gate on platform + * so POSIX keeps the cheaper no-shell path. Exported so the static-grep tripwire + * (test/gbrain-spawn-windows-shell.test.ts) can assert every gbrain/brain-sync + * spawn carries it. + */ +export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; + export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; @@ -166,6 +178,7 @@ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): Spaw cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); } @@ -198,6 +211,7 @@ export function spawnGbrainAsync( stdio: opts.stdio || ["ignore", "pipe", "pipe"], cwd: opts.cwd, env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }), + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); } @@ -212,5 +226,6 @@ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): s cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); } diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index ae760067b..f6332cf6b 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -35,7 +35,7 @@ import { } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; -import { buildGbrainEnv } from "./gbrain-exec"; +import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; export type LocalEngineStatus = | "ok" @@ -113,6 +113,7 @@ export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null { timeout: 2_000, stdio: ["ignore", "ignore", "ignore"], env: e, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); result = "gbrain"; } catch { @@ -135,6 +136,7 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string { timeout: 2_000, stdio: ["ignore", "pipe", "ignore"], env: e, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); result = out.trim().split("\n")[0] || ""; } catch { @@ -241,6 +243,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { timeout: PROBE_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: env ?? process.env }), + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); return "ok"; } catch (err) { diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index 1e8309171..8856b5215 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -11,6 +11,7 @@ import { execFileSync, spawnSync } from "child_process"; import { withErrorContext } from "./gstack-memory-helpers"; +import { NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; export interface SourceState { /** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */ @@ -87,6 +88,7 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState { timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); } catch (err) { const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; @@ -160,6 +162,7 @@ export async function ensureSourceRegistered( encoding: "utf-8", timeout: 30_000, env, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); if (rm.status !== 0) { throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`); @@ -173,6 +176,7 @@ export async function ensureSourceRegistered( encoding: "utf-8", timeout: 30_000, env, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); if (add.status !== 0) { throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`); @@ -198,6 +202,7 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, + shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); } catch { return null; diff --git a/test/gbrain-spawn-windows-shell.test.ts b/test/gbrain-spawn-windows-shell.test.ts new file mode 100644 index 000000000..d968d2f68 --- /dev/null +++ b/test/gbrain-spawn-windows-shell.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); + +// #1731 tripwire. Windows can't spawn the `gbrain` shim (gbrain.cmd) or the bash +// shebang script gstack-brain-sync without a shell; the fix gates `shell: true` +// behind NEEDS_SHELL_ON_WINDOWS. These static checks fail CI if a refactor adds +// a gbrain/brain-sync child spawn without the Windows shell flag, since macOS/ +// Linux CI can't exercise the Windows path at runtime. +describe("#1731 gbrain spawns carry the Windows shell flag", () => { + test("NEEDS_SHELL_ON_WINDOWS is platform-gated in gbrain-exec.ts", () => { + const src = read("lib/gbrain-exec.ts"); + 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`, () => { + 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); + }); + } + + test("orchestrator brain-sync spawns carry the Windows shell flag", () => { + const src = read("bin/gstack-gbrain-sync.ts"); + const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0; + expect(brainSyncSpawns).toBe(2); + // Both spawnSync(brainSyncPath, ...) blocks must include the shell flag. + const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0; + expect(withShell).toBe(2); + }); +});