diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabd..ce7f87121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [1.60.2.0] - 2026-07-15 + +## **Windows Codex installs now ship the runtime modules their GBrain tools import.** +## **Worktree sync preserves path boundaries and refuses to widen maintenance across the whole brain.** + +The Windows Codex runtime could install executable GStack helpers without the sibling `lib` modules they import. Once the helpers ran, source sync had three more sharp edges: equivalent Windows path spellings looked like drift, paths containing spaces passed through shell parsing, and a source-scoped maintenance request could assume a CLI capability the installed GBrain did not expose. This release closes all four defects as one boundary-hardening patch. + +### The numbers that matter + +Source: the DEV-206 regression suite and adjacent GBrain orchestration tests (`bun test test/dev-206-windows-gbrain.test.ts test/gbrain-cycle-completed.test.ts test/gbrain-dream-stage.test.ts test/gbrain-exec-invariant.test.ts test/gbrain-sources.test.ts test/gbrain-spawn-windows-shell.test.ts test/gstack-gbrain-sync.test.ts`). + +| Windows worktree-sync boundary | Before | After | +|---|---:|---:| +| Required runtime module trees installed with Codex helpers | 0 | 1 (`lib`) | +| Equivalent slash/case path forms that trigger re-registration | possible | 0 in regression coverage | +| Shell parsing layers around GBrain argv | 1 | 0 | +| Brain-wide maintenance fallbacks from a source-scoped request | possible | 0 (fails closed) | +| Focused regression and orchestration tests | — | 92 passing | + +### What this means for you + +Codex-hosted GStack tools keep their imports after both setup and upgrade. A Windows worktree path keeps its spaces as one argument, case or slash differences do not cause a destructive source remove/re-add, and `/sync-gbrain` reports an actionable compatibility error when the installed CLI cannot perform source-scoped maintenance. It never substitutes a brain-wide cycle silently. + +### Itemized changes + +#### Fixed + +- Codex runtime metadata and `./setup` now install `lib` beside shipped `bin` helpers in both the global runtime and project sidecar. +- Source drift checks resolve Windows drive/UNC paths, normalize separators, and compare case-insensitively before considering destructive re-registration or hostname-fold migration. +- GBrain subprocesses resolve a direct executable and use structural argv with `shell: false`; Windows `.cmd`, `.bat`, and `.ps1` launchers are rejected with setup remediation instead of rebuilding a shell command. +- The dream stage probes `gbrain dream --help` for `--source` support before acquiring its maintenance marker. Missing capability or an unresolvable worktree returns an error and never falls back to bare `gbrain dream`. + +#### For contributors + +- Cross-platform fake GBrain harnesses now compile executable test shims, so spaced-path and direct-spawn behavior is exercised without shell quoting artifacts. +- DEV-206 coverage simulates Codex install/upgrade, verifies normalized drift, rejects unsafe Windows launchers, and runs the real wrapper against a brain-wide-only fake CLI to prove no maintenance dispatch occurs. + ## [1.60.1.0] - 2026-07-09 ## **The /autoplan dual-voice eval is back on the board, catching real regressions.** diff --git a/README.md b/README.md index af534d2c5..dfd91b06b 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/open-gstack-browser` | **GStack Browser** — launch GStack Browser with sidebar, anti-bot stealth, auto model routing (Sonnet for actions, Opus for analysis), one-click cookie import, and Claude Code integration. Clean up pages, take smart screenshots, edit CSS, and pass info back to your terminal. | | `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. | | `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). | -| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. | +| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Source-scoped maintenance fails closed when the installed GBrain only supports a brain-wide cycle. Idempotent; safe to re-run. | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | | `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. | | `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | @@ -405,7 +405,7 @@ Four paths, pick one: After init, the skill offers to register gbrain as an MCP server for Claude Code (`claude mcp add gbrain -- gbrain serve`) so `gbrain search`, `gbrain put`, etc. show up as first-class typed tools — not bash shell-outs. -**Keeping the brain current.** Run `/sync-gbrain` from any repo to re-index its code into gbrain (incremental by default, `--full` for a full reindex, `--dry-run` to preview). The skill registers the cwd as a federated source via `gbrain sources add`, runs `gbrain sync --strategy code`, and writes a `## GBrain Search Guidance` block to your project's CLAUDE.md so the agent prefers `gbrain search`/`code-def`/`code-refs` over Grep. The block is removed automatically if the capability check fails — no stale guidance pointing at tools that aren't installed. +**Keeping the brain current.** Run `/sync-gbrain` from any repo to re-index its code into gbrain (incremental by default, `--full` for a full reindex, `--dry-run` to preview). The skill registers the cwd as a federated source via `gbrain sources add`, runs `gbrain sync --strategy code`, and writes a `## GBrain Search Guidance` block to your project's CLAUDE.md so the agent prefers `gbrain search`/`code-def`/`code-refs` over Grep. The block is removed automatically if the capability check fails — no stale guidance pointing at tools that aren't installed. If a full sync needs call-graph maintenance, GStack requires the installed CLI to support `gbrain dream --source`; it will not silently widen that request to a brain-wide cycle. **Per-remote trust policy.** Each repo on your machine gets one of three tiers: diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md index ec1144c9a..d0728b599 100644 --- a/USING_GBRAIN_WITH_GSTACK.md +++ b/USING_GBRAIN_WITH_GSTACK.md @@ -135,11 +135,13 @@ The skill runs three stages — code, memory, brain-sync — independently. A fa **What it does on a fresh worktree:** 1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs. -2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`. +2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`, and normalizes Windows slash/case differences before deciding that a registered path has drifted. 3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run--/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min–24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over. 4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured. 5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed. +`--full` may also need a call-graph maintenance cycle for this worktree. GStack first checks whether the installed GBrain exposes `gbrain dream --source`; if it does not, sync reports an error and leaves maintenance to the operator. It never substitutes a brain-wide `gbrain dream` for a worktree-scoped request. + **The watermark.** Sync state advances by commit hash. If gbrain hits a file it can't index (5 MB hard limit per file, or a file vanished mid-sync), the watermark stays put and subsequent syncs retry. To acknowledge an unfixable failure and move past it: ```bash diff --git a/VERSION b/VERSION index c4190e004..762f17554 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.60.2.0 diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 07a94af3f..df1bcc08b 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -37,7 +37,7 @@ import { createHash } from "crypto"; import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; -import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; +import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, sourcePathsEqual, type CycleStatus } from "../lib/gbrain-sources"; import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; @@ -499,7 +499,7 @@ export function planHostnameFoldMigration( if (oldPath === null) { return { kind: "none", reason: "no-legacy-source" }; } - if (oldPath !== currentRoot) { + if (!sourcePathsEqual(oldPath, currentRoot)) { return { kind: "skipped-path-drift", oldId: legacyPathHashId, @@ -1188,11 +1188,43 @@ export function shouldRunDream(args: CliArgs, cycle: CycleStatus | null): boolea return false; } +export type SourceScopedDreamPlan = + | { ok: true; args: string[] } + | { ok: false; error: string }; + /** - * Run `gbrain dream` — the brain-global maintenance cycle whose - * resolve_symbol_edges phase builds the call graph. Runs LOCK-FREE (called - * after the sync lock releases) so it never freezes sibling worktrees; the - * `.dream-in-progress` marker dedupes concurrent dreams instead. + * Turn `gbrain dream --help` into a source-scoped command or a fail-closed + * compatibility error. A brain-wide `dream` is never an implicit fallback for + * a worktree-scoped sync request. + */ +export function planSourceScopedDream( + sourceId: string, + helpStatus: number | null, + helpOutput: string, +): SourceScopedDreamPlan { + if (helpStatus !== 0) { + return { + ok: false, + error: + `could not verify source-scoped gbrain dream support (help exited ${helpStatus}); ` + + "upgrade gbrain or review a brain-wide maintenance run manually; refusing to widen scope", + }; + } + if (!/--source(?:\s|=|<|\[|$)/m.test(helpOutput)) { + return { + ok: false, + error: + "installed gbrain dream does not support --source; upgrade gbrain or review a " + + "brain-wide maintenance run manually; refusing to widen scope", + }; + } + return { ok: true, args: ["dream", "--source", sourceId] }; +} + +/** + * Run a proven source-scoped `gbrain dream --source ` maintenance cycle. + * Runs LOCK-FREE (called after the sync lock releases) so it never freezes + * sibling worktrees; the `.dream-in-progress` marker dedupes concurrent dreams. * * Returns a StageResult (never throws). SKIP (ran:false, ok:true) for: dry-run * preview, local engine not ok, or a fresh marker present. ERR (ran:true, @@ -1213,7 +1245,7 @@ export async function runDream(args: CliArgs): Promise { duration_ms: 0, summary: sourceId ? `would: gbrain dream --source ${sourceId} (build this source's call graph)` - : "would: gbrain dream (call-graph build)", + : "would refuse: cannot derive a worktree source id (no brain-wide fallback)", }; } @@ -1224,6 +1256,45 @@ export async function runDream(args: CliArgs): Promise { return skipStageForLocalStatus("dream", localStatus, t0); } + const root = repoRoot(); + if (!root) { + return { + name: "dream", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: "cannot derive a worktree source id; refusing to run brain-wide gbrain dream", + }; + } + const sourceId = deriveCodeSourceId(root); + let dreamPlan: SourceScopedDreamPlan; + try { + const help = spawnGbrain(["dream", "--help"], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + baseEnv: process.env, + }); + dreamPlan = planSourceScopedDream( + sourceId, + help.status, + `${help.stdout || ""}${help.stderr || ""}`, + ); + } catch (err) { + dreamPlan = { + ok: false, + error: `could not inspect gbrain dream capability: ${(err as Error).message}; refusing to widen scope`, + }; + } + if (!dreamPlan.ok) { + return { + name: "dream", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: dreamPlan.error, + }; + } + // Dedupe concurrent dreams across worktrees (lock-free path). if (!acquireDreamMarker()) { const pid = dreamMarkerPid(); @@ -1243,18 +1314,6 @@ export async function runDream(args: CliArgs): Promise { DEFAULT_DREAM_TIMEOUT_MS, ); - // Scope the cycle to THIS worktree's code source: `gbrain dream --source `. - // Verified empirically (not just from `gbrain --help`): plain `gbrain dream` - // cycles the brain's default source and never runs the source-scoped `extract` - // phase for our code source, so the call graph for the pinned source stays - // empty. `gbrain dream --source ` runs the per-source cycle (the form - // `gbrain doctor` recommends for stale sources) and is what actually populates - // code-callers/code-callees for this worktree. Falls back to plain `dream` - // only when we can't derive the source id (not in a git repo). - const root = repoRoot(); - const sourceId = root ? deriveCodeSourceId(root) : null; - const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"]; - // spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv. // // We CAPTURE output (pipe) rather than inherit because `gbrain dream` exits 0 @@ -1268,7 +1327,7 @@ export async function runDream(args: CliArgs): Promise { } let result: ReturnType; try { - result = spawnGbrain(dreamArgs, { + result = spawnGbrain(dreamPlan.args, { stdio: ["ignore", "pipe", "pipe"], timeout: dreamTimeoutMs, baseEnv: process.env, diff --git a/hosts/codex.ts b/hosts/codex.ts index 7dc80ea87..a6f32da5a 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -42,14 +42,14 @@ const codex: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, }, sidecar: { path: '.agents/skills/gstack', - symlinks: ['bin', 'browse', 'review', 'qa', 'ETHOS.md'], + symlinks: ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md'], }, install: { diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 0cb1ddc57..761f02a04 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -125,17 +125,57 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process } /** - * 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. + * Windows still needs a shell for bash helpers such as `gstack-brain-sync`. + * Direct gbrain calls deliberately do NOT use this flag: planGbrainSpawn resolves + * Bun's executable shim and preserves argv structurally (DEV-206). Exported for + * the remaining shebang-script spawns and legacy literal-argument probes. */ export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; +export interface GbrainSpawnPlan { + command: string; + args: string[]; + shell: boolean; +} + +/** + * Build a structural gbrain invocation. Supported Windows installs use Bun's + * direct `.exe` shim, so argv values (especially worktree paths containing + * spaces) never pass through `cmd.exe` tokenization. + * + * A `.cmd`/`.bat`/`.ps1` launcher would require rebuilding the command as a + * shell string. Fail closed instead of risking argument splitting or command + * injection; `bin/gstack-gbrain-install` uses `bun link`, which installs the + * directly executable shim this contract requires. + */ +export function planGbrainSpawn( + args: string[], + resolvedExecutable: string | null, + platform: NodeJS.Platform = process.platform, +): GbrainSpawnPlan { + const command = resolvedExecutable || "gbrain"; + if (platform === "win32" && resolvedExecutable && !/\.(?:exe|com)$/i.test(resolvedExecutable)) { + throw new Error( + `unsafe Windows gbrain launcher at ${resolvedExecutable}; ` + + "re-run /setup-gbrain so bun link installs a direct gbrain.exe shim", + ); + } + return { command, args: [...args], shell: false }; +} + +/** Resolve gbrain against the exact PATH that will be passed to the child. */ +export function resolveGbrainExecutable(env: NodeJS.ProcessEnv = process.env): string | null { + try { + return Bun.which("gbrain", { PATH: env.PATH }); + } catch { + return null; + } +} + +function gbrainSpawnPlan(args: string[], env: NodeJS.ProcessEnv): GbrainSpawnPlan { + return planGbrainSpawn(args, resolveGbrainExecutable(env)); +} + export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; @@ -159,13 +199,15 @@ export interface SpawnGbrainOptions { * `stderr` exactly as they would with `spawnSync` directly. */ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns { - return spawnSync("gbrain", args, { + const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }); + const plan = gbrainSpawnPlan(args, env); + return spawnSync(plan.command, plan.args, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, 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 + env, + shell: plan.shell, }); } @@ -194,11 +236,13 @@ export function spawnGbrainAsync( args: string[], opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {}, ): ChildProcess { - return spawn("gbrain", args, { + const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }); + const plan = gbrainSpawnPlan(args, env); + return spawn(plan.command, plan.args, { 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 + env, + shell: plan.shell, }); } @@ -207,12 +251,14 @@ export function spawnGbrainAsync( * for callers that want to surface gbrain's stderr as the error message. */ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string { - return execFileSync("gbrain", args, { + const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }); + const plan = gbrainSpawnPlan(args, env); + return execFileSync(plan.command, plan.args, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, 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 + env, + shell: plan.shell, }); } diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index cdaa25eaa..b2b610239 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -9,9 +9,9 @@ * Per /plan-eng-review D3 (DRY extraction). */ -import { execFileSync, spawnSync } from "child_process"; +import { resolve, win32 } from "path"; import { withErrorContext } from "./gstack-memory-helpers"; -import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { execGbrainJson, execGbrainText, spawnGbrain } from "./gbrain-exec"; export interface SourceState { /** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */ @@ -72,6 +72,34 @@ export interface EnsureOptions { env?: NodeJS.ProcessEnv; } +function looksLikeWindowsPath(value: string): boolean { + return /^[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value); +} + +/** + * Compare source paths using the host filesystem's path semantics. Windows + * drive/UNC paths are resolved, separator-normalized, and case-folded before + * comparison so `C:\\Repo` and `c:/repo` cannot trigger destructive drift. + * POSIX paths remain case-sensitive. + */ +export function sourcePathsEqual( + left: string, + right: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const useWindowsSemantics = + platform === "win32" || looksLikeWindowsPath(left) || looksLikeWindowsPath(right); + if (useWindowsSemantics) { + const normalizeWindows = (value: string): string => + win32 + .resolve(value.replace(/\//g, "\\")) + .replace(/^\\\\\?\\/, "") + .toLowerCase(); + return normalizeWindows(left) === normalizeWindows(right); + } + return resolve(left) === resolve(right); +} + /** * Probe the registration state of a source by id. * @@ -83,13 +111,7 @@ export interface EnsureOptions { export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState { let stdout: string; try { - stdout = execFileSync("gbrain", ["sources", "list", "--json"], { - encoding: "utf-8", - timeout: 30_000, - stdio: ["ignore", "pipe", "pipe"], - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }); + stdout = execGbrainText(["sources", "list", "--json"], { baseEnv: env }); } catch (err) { const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; const stderr = e.stderr?.toString() || ""; @@ -144,7 +166,10 @@ export async function ensureSourceRegistered( // Disambiguate match-but-different-path let state: SourceState = probed; - if (probed.status === "match" && probed.registered_path !== path) { + if ( + probed.status === "match" && + (!probed.registered_path || !sourcePathsEqual(probed.registered_path, path)) + ) { state = { status: "drift", registered_path: probed.registered_path }; } @@ -158,12 +183,7 @@ export async function ensureSourceRegistered( // For drift, remove first. if (state.status === "drift") { - const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], { - encoding: "utf-8", - timeout: 30_000, - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }); + const rm = spawnGbrain(["sources", "remove", id, "--yes"], { baseEnv: env }); if (rm.status !== 0) { throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`); } @@ -172,12 +192,7 @@ export async function ensureSourceRegistered( // Add. const addArgs = ["sources", "add", id, "--path", path]; if (federated) addArgs.push("--federated"); - const add = spawnSync("gbrain", addArgs, { - encoding: "utf-8", - timeout: 30_000, - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }); + const add = spawnGbrain(addArgs, { baseEnv: env }); if (add.status !== 0) { throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`); } @@ -195,27 +210,11 @@ export async function ensureSourceRegistered( * variant selection. */ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | null { - let stdout: string; - try { - stdout = execFileSync("gbrain", ["sources", "list", "--json"], { - encoding: "utf-8", - timeout: 30_000, - stdio: ["ignore", "pipe", "pipe"], - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }); - } catch { - return null; - } - - try { - const match = parseSourcesList(JSON.parse(stdout)).find((s) => s.id === id); - if (!match) return null; - if (typeof match.page_count !== "number") return null; - return match.page_count; - } catch { - return null; - } + const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env }); + if (!raw) return null; + const match = parseSourcesList(raw).find((s) => s.id === id); + if (!match || typeof match.page_count !== "number") return null; + return match.page_count; } /** diff --git a/package.json b/package.json index 846438a60..78b98a86f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.60.1.0", + "version": "1.60.2.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 275236cd3..351916f50 100755 --- a/setup +++ b/setup @@ -749,7 +749,7 @@ create_agents_sidecar() { mkdir -p "$agents_gstack" # Sidecar directories that skills reference at runtime - for asset in bin browse review qa; do + for asset in bin lib browse review qa; do local src="$SOURCE_GSTACK_DIR/$asset" local dst="$agents_gstack/$asset" if [ -d "$src" ] || [ -f "$src" ]; then @@ -796,6 +796,9 @@ create_codex_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$codex_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist" fi diff --git a/test/dev-206-windows-gbrain.test.ts b/test/dev-206-windows-gbrain.test.ts new file mode 100644 index 000000000..37a093f5c --- /dev/null +++ b/test/dev-206-windows-gbrain.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { delimiter, dirname, join, resolve } from "path"; +import { spawnSync } from "child_process"; + +import codex from "../hosts/codex"; +import { planGbrainSpawn } from "../lib/gbrain-exec"; +import { sourcePathsEqual } from "../lib/gbrain-sources"; +import { planSourceScopedDream } from "../bin/gstack-gbrain-sync"; + +const ROOT = join(import.meta.dir, ".."); +const SETUP = readFileSync(join(ROOT, "setup"), "utf-8"); + +function setupFunction(name: string): string { + const start = SETUP.indexOf(`${name}() {`); + const end = SETUP.indexOf("\n}\n", start); + if (start < 0 || end < 0) throw new Error(`Could not extract ${name} from setup`); + return SETUP.slice(start, end + 2); +} + +function bashExecutable(): string { + if (process.platform === "win32") { + const git = Bun.which("git"); + if (git) { + const gitBash = resolve(dirname(git), "..", "bin", "bash.exe"); + if (existsSync(gitBash)) return gitBash; + } + } + return Bun.which("bash") || "bash"; +} + +describe("DEV-206 Windows Codex runtime and GBrain regressions", () => { + test("Codex runtime metadata and setup include lib beside imported bin scripts", () => { + expect(codex.runtimeRoot.globalSymlinks).toContain("lib"); + expect(codex.sidecar?.symlinks).toContain("lib"); + + const runtimeStart = SETUP.indexOf("create_codex_runtime_root() {"); + const runtimeEnd = SETUP.indexOf("\n}\n", runtimeStart); + const runtimeBody = SETUP.slice(runtimeStart, runtimeEnd); + expect(runtimeBody).toContain('_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"'); + + const sidecarStart = SETUP.indexOf("create_agents_sidecar() {"); + const sidecarEnd = SETUP.indexOf("\n}\n", sidecarStart); + const sidecarBody = SETUP.slice(sidecarStart, sidecarEnd); + expect(sidecarBody).toMatch(/for asset in[^\n]*\blib\b/); + }); + + test("simulated Windows Codex install and upgrade preserve lib imports", () => { + const tmp = mkdtempSync(join(tmpdir(), "gstack-dev-206-runtime-")); + try { + const source = join(tmp, "source"); + const runtime = join(tmp, "codex runtime"); + mkdirSync(join(source, ".agents", "skills", "gstack"), { recursive: true }); + mkdirSync(join(source, "bin"), { recursive: true }); + mkdirSync(join(source, "lib"), { recursive: true }); + writeFileSync(join(source, ".agents", "skills", "gstack", "SKILL.md"), "---\nname: gstack\n---\n"); + writeFileSync(join(source, "lib", "runtime-marker.ts"), 'export const marker = "runtime-ok";\n'); + const detector = join(source, "bin", "gstack-gbrain-detect"); + writeFileSync( + detector, + '#!/usr/bin/env bun\nimport { marker } from "../lib/runtime-marker.ts";\nconsole.log(marker);\n', + ); + chmodSync(detector, 0o755); + + const shell = [ + "set -e", + "IS_WINDOWS=1", + setupFunction("_link_or_copy"), + setupFunction("create_codex_runtime_root"), + 'create_codex_runtime_root "$1" "$2"', + ].join("\n"); + for (let attempt = 0; attempt < 2; attempt += 1) { + const install = spawnSync(bashExecutable(), ["-c", shell, "dev-206", source, runtime], { + encoding: "utf-8", + timeout: 15_000, + }); + expect(install.status).toBe(0); + expect(install.stderr).toBe(""); + } + + const run = spawnSync(process.execPath, [join(runtime, "bin", "gstack-gbrain-detect")], { + encoding: "utf-8", + timeout: 15_000, + }); + expect(run.status).toBe(0); + expect(run.stdout.trim()).toBe("runtime-ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("equivalent Windows slash and case forms compare as one resolved source", () => { + expect(sourcePathsEqual("C:\\Work Trees\\Example Repo", "c:/work trees/example repo", "win32")).toBe(true); + expect(sourcePathsEqual("C:\\Work Trees\\Example Repo", "D:/work trees/example repo", "win32")).toBe(false); + }); + + test("Windows source registration keeps a path containing spaces as one argv value", () => { + const sourcePath = "C:\\Work Trees\\Example Repo"; + const args = ["sources", "add", "gstack-code-test", "--path", sourcePath, "--federated"]; + const plan = planGbrainSpawn(args, "C:\\Program Files\\Bun\\gbrain.exe", "win32"); + + expect(plan.command).toBe("C:\\Program Files\\Bun\\gbrain.exe"); + expect(plan.args).toEqual(args); + expect(plan.args[4]).toBe(sourcePath); + expect(plan.shell).toBe(false); + }); + + test("Windows source registration rejects shell-only launchers", () => { + for (const extension of ["cmd", "bat", "ps1"]) { + expect(() => + planGbrainSpawn( + ["sources", "list", "--json"], + `C:\\Program Files\\GBrain\\gbrain.${extension}`, + "win32", + ), + ).toThrow("unsafe Windows gbrain launcher"); + } + }); + + test("dream capability planning refuses a brain-wide fallback", () => { + const supported = planSourceScopedDream( + "gstack-code-test", + 0, + "Usage: gbrain dream [--source ]", + ); + expect(supported).toEqual({ ok: true, args: ["dream", "--source", "gstack-code-test"] }); + + const brainWideOnly = planSourceScopedDream( + "gstack-code-test", + 0, + "Usage: gbrain dream", + ); + expect(brainWideOnly.ok).toBe(false); + if (!brainWideOnly.ok) { + expect(brainWideOnly.error).toContain("does not support --source"); + expect(brainWideOnly.error).toContain("refusing to widen scope"); + } + }); + + test("dream execution probes capability and never launches a brain-wide fallback", () => { + const tmp = mkdtempSync(join(tmpdir(), "gstack-dev-206-dream-")); + try { + const binDir = join(tmp, "bin"); + const gbrainHome = join(tmp, ".gbrain"); + const gstackHome = join(tmp, ".gstack"); + const logPath = join(tmp, "gbrain-calls.jsonl"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(gbrainHome, { recursive: true }); + mkdirSync(gstackHome, { recursive: true }); + writeFileSync( + join(gbrainHome, "config.json"), + JSON.stringify({ engine: "pglite", database_url: "pglite:///synthetic" }), + ); + + const fakeSource = join(tmp, "fake-gbrain.ts"); + const fakeBinary = join(binDir, process.platform === "win32" ? "gbrain.exe" : "gbrain"); + writeFileSync( + fakeSource, + `import { appendFileSync } from "fs"; +const args = process.argv.slice(2); +appendFileSync(process.env.FAKE_GBRAIN_LOG!, JSON.stringify(args) + "\\n"); +if (args.join(" ") === "--version") { console.log("gbrain 0.41.0"); process.exit(0); } +if (args.join(" ") === "sources list --json") { console.log('{"sources":[]}'); process.exit(0); } +if (args.join(" ") === "dream --help") { console.log("Usage: gbrain dream"); process.exit(0); } +process.exit(91); +`, + ); + const built = spawnSync( + process.execPath, + ["build", fakeSource, "--compile", "--outfile", fakeBinary], + { encoding: "utf-8", timeout: 30_000 }, + ); + expect(built.status).toBe(0); + chmodSync(fakeBinary, 0o755); + + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(childEnv)) { + if (key.toUpperCase() === "PATH") delete childEnv[key]; + } + Object.assign(childEnv, { + HOME: tmp, + GBRAIN_HOME: gbrainHome, + GSTACK_HOME: gstackHome, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, + FAKE_GBRAIN_LOG: logPath, + }); + + const result = spawnSync( + process.execPath, + [ + join(ROOT, "bin", "gstack-gbrain-sync.ts"), + "--dream", + "--no-code", + "--no-memory", + "--no-brain-sync", + ], + { + cwd: ROOT, + encoding: "utf-8", + timeout: 15_000, + env: childEnv, + }, + ); + const output = `${result.stdout || ""}${result.stderr || ""}`; + expect(result.status).toBe(1); + expect(output).toContain("does not support --source"); + expect(output).toContain("refusing to widen scope"); + + const calls = readFileSync(logPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(calls).toContainEqual(["dream", "--help"]); + expect(calls).not.toContainEqual(["dream"]); + expect(calls.some((call) => call[0] === "dream" && call[1] !== "--help")).toBe(false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/gbrain-cycle-completed.test.ts b/test/gbrain-cycle-completed.test.ts index 2c9009bf2..67bf1ee1b 100644 --- a/test/gbrain-cycle-completed.test.ts +++ b/test/gbrain-cycle-completed.test.ts @@ -9,13 +9,39 @@ * start; explicit env is the only reliable redirect). */ -import { describe, it, expect } from "bun:test"; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync, chmodSync } from "fs"; +import { afterAll, describe, it, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync, chmodSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { delimiter, join } from "path"; +import { spawnSync } from "child_process"; import { cycleCompleted } from "../lib/gbrain-sources"; +const FAKE_BUILD_ROOT = mkdtempSync(join(tmpdir(), "gbrain-cycle-fake-bin-")); +const FAKE_SOURCE = join(FAKE_BUILD_ROOT, "fake-gbrain.ts"); +const FAKE_BINARY = join(FAKE_BUILD_ROOT, process.platform === "win32" ? "gbrain.exe" : "gbrain"); + +writeFileSync( + FAKE_SOURCE, + `const args = process.argv.slice(2); +if (args.join(" ") !== "doctor --json --fast") process.exit(1); +const exitCode = Number(process.env.FAKE_GBRAIN_DOCTOR_EXIT || "0"); +if (exitCode !== 0) process.exit(exitCode); +process.stdout.write(process.env.FAKE_GBRAIN_DOCTOR_JSON || ""); +`, +); +const fakeBuild = spawnSync( + process.execPath, + ["build", FAKE_SOURCE, "--compile", "--outfile", FAKE_BINARY], + { encoding: "utf-8", timeout: 30_000 }, +); +if (fakeBuild.status !== 0) { + throw new Error(`Could not build fake gbrain: ${fakeBuild.stderr || fakeBuild.stdout}`); +} +chmodSync(FAKE_BINARY, 0o755); + +afterAll(() => rmSync(FAKE_BUILD_ROOT, { recursive: true, force: true })); + interface FakeSetup { env: NodeJS.ProcessEnv; cleanup: () => void; @@ -25,33 +51,17 @@ interface FakeSetup { * Fake `gbrain`: * doctor --json --fast → echo $DOCTOR_JSON (or exit $DOCTOR_EXIT if set) * anything else → exit 1 - * The doctor payload is baked into the script so each test gets its own shim. + * Each test supplies its doctor result through the child environment while all + * tests reuse one compiled, cross-platform executable shim. */ function makeFakeGbrain(opts: { doctorJson?: string; doctorExit?: number }): FakeSetup { - const tmp = mkdtempSync(join(tmpdir(), "gbrain-cycle-test-")); - const bindir = join(tmp, "bin"); - mkdirSync(bindir, { recursive: true }); - - const exit = opts.doctorExit ?? 0; - // Single-quote the JSON for the heredoc-free echo; escape embedded single quotes. - const payload = (opts.doctorJson ?? "").replace(/'/g, "'\\''"); - const fake = `#!/bin/sh -case "$1 $2 $3" in - "doctor --json --fast") - if [ ${exit} -ne 0 ]; then exit ${exit}; fi - printf '%s' '${payload}' - exit 0 - ;; -esac -echo "fake gbrain: unknown command: $@" >&2 -exit 1 -`; - const fakePath = join(bindir, "gbrain"); - writeFileSync(fakePath, fake); - chmodSync(fakePath, 0o755); - - const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` }; - return { env, cleanup: () => rmSync(tmp, { recursive: true, force: true }) }; + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${FAKE_BUILD_ROOT}${delimiter}${process.env.PATH || ""}`, + FAKE_GBRAIN_DOCTOR_EXIT: String(opts.doctorExit ?? 0), + FAKE_GBRAIN_DOCTOR_JSON: opts.doctorJson ?? "", + }; + return { env, cleanup: () => undefined }; } const SRC = "gstack-code-gstack-c5994d95"; diff --git a/test/gbrain-sources.test.ts b/test/gbrain-sources.test.ts index 9486eb06c..270ac9b36 100644 --- a/test/gbrain-sources.test.ts +++ b/test/gbrain-sources.test.ts @@ -7,13 +7,60 @@ * invocations. The same trick `test/gstack-gbrain-source-wireup.test.ts` uses. */ -import { describe, it, expect } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs"; +import { afterAll, describe, it, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { delimiter, join } from "path"; +import { spawnSync } from "child_process"; import { ensureSourceRegistered, probeSource, sourcePageCount } from "../lib/gbrain-sources"; +const FAKE_BUILD_ROOT = mkdtempSync(join(tmpdir(), "gbrain-sources-fake-bin-")); +const FAKE_SOURCE = join(FAKE_BUILD_ROOT, "fake-gbrain.ts"); +const FAKE_BINARY = join(FAKE_BUILD_ROOT, process.platform === "win32" ? "gbrain.exe" : "gbrain"); + +writeFileSync( + FAKE_SOURCE, + `import { appendFileSync, readFileSync, writeFileSync } from "fs"; +const args = process.argv.slice(2); +const statePath = process.env.FAKE_GBRAIN_STATE_PATH!; +const logPath = process.env.FAKE_GBRAIN_LOG_PATH!; +appendFileSync(logPath, args.join(" ") + "\\n"); +if (args[0] === "--version") { console.log("gbrain 0.25.1"); process.exit(0); } +if (args[0] !== "sources") process.exit(1); +const state = JSON.parse(readFileSync(statePath, "utf-8")); +if (args[1] === "list") { console.log(JSON.stringify(state)); process.exit(0); } +if (args[1] === "add") { + const pathIndex = args.indexOf("--path"); + state.sources.push({ + id: args[2], + local_path: pathIndex >= 0 ? args[pathIndex + 1] : "", + federated: args.includes("--federated"), + page_count: 0, + }); + writeFileSync(statePath, JSON.stringify(state)); + process.exit(0); +} +if (args[1] === "remove") { + state.sources = state.sources.filter((source: { id?: string }) => source.id !== args[2]); + writeFileSync(statePath, JSON.stringify(state)); + process.exit(0); +} +process.exit(1); +`, +); +const fakeBuild = spawnSync( + process.execPath, + ["build", FAKE_SOURCE, "--compile", "--outfile", FAKE_BINARY], + { encoding: "utf-8", timeout: 30_000 }, +); +if (fakeBuild.status !== 0) { + throw new Error(`Could not build fake gbrain: ${fakeBuild.stderr || fakeBuild.stdout}`); +} +chmodSync(FAKE_BINARY, 0o755); + +afterAll(() => rmSync(FAKE_BUILD_ROOT, { recursive: true, force: true })); + interface FakeGbrainSetup { bindir: string; statePath: string; @@ -38,59 +85,21 @@ interface FakeGbrainSetup { */ function makeFakeGbrain(initialState: { sources: Array<{ id: string; local_path: string; federated?: boolean; page_count?: number }> }): FakeGbrainSetup { const tmp = mkdtempSync(join(tmpdir(), "gbrain-sources-test-")); - const bindir = join(tmp, "bin"); - mkdirSync(bindir, { recursive: true }); + const bindir = FAKE_BUILD_ROOT; const statePath = join(tmp, "state.json"); const logPath = join(tmp, "calls.log"); writeFileSync(statePath, JSON.stringify(initialState)); writeFileSync(logPath, ""); - const fake = `#!/bin/sh -echo "$@" >> "${logPath}" -case "$1 $2" in - "--version ") - echo "gbrain 0.25.1" - exit 0 - ;; - "sources list") - cat "${statePath}" - exit 0 - ;; - "sources add") - ID="$3" - shift 3 - PATH_VAL="" - FED="false" - while [ $# -gt 0 ]; do - case "$1" in - --path) PATH_VAL="$2"; shift 2 ;; - --federated) FED="true"; shift ;; - *) shift ;; - esac - done - NEW=$(jq --arg id "$ID" --arg path "$PATH_VAL" --argjson fed "$FED" \ - '.sources += [{id: $id, local_path: $path, federated: $fed, page_count: 0}]' "${statePath}") - echo "$NEW" > "${statePath}" - exit 0 - ;; - "sources remove") - ID="$3" - NEW=$(jq --arg id "$ID" '.sources = (.sources | map(select(.id != $id)))' "${statePath}") - echo "$NEW" > "${statePath}" - exit 0 - ;; -esac -echo "fake gbrain: unknown command: $@" >&2 -exit 1 -`; - const fakePath = join(bindir, "gbrain"); - writeFileSync(fakePath, fake); - chmodSync(fakePath, 0o755); - // Build the env override we'll pass to helper calls. We do NOT mutate // process.env globally because Bun's execFileSync caches PATH at process // start; explicit env is the only reliable way to redirect spawn-time PATH. - const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` }; + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${bindir}${delimiter}${process.env.PATH || ""}`, + FAKE_GBRAIN_STATE_PATH: statePath, + FAKE_GBRAIN_LOG_PATH: logPath, + }; return { bindir, @@ -190,6 +199,40 @@ describe("ensureSourceRegistered", () => { expect(log).not.toContain("sources add"); fake.cleanup(); }); + + it("reuses equivalent Windows slash/case paths without destructive re-registration", async () => { + const fake = makeFakeGbrain({ + sources: [{ id: "gstack-code-foo", local_path: "C:\\Work Trees\\Example Repo" }], + }); + const result = await ensureSourceRegistered( + "gstack-code-foo", + "c:/work trees/example repo", + { env: fake.env }, + ); + expect(result.changed).toBe(false); + expect(result.state.status).toBe("match"); + + const log = readFileSync(fake.logPath, "utf-8"); + expect(log).not.toContain("sources remove"); + expect(log).not.toContain("sources add"); + fake.cleanup(); + }); + + it("registers a Windows worktree path containing spaces as one argument", async () => { + const fake = makeFakeGbrain({ sources: [] }); + const sourcePath = "C:\\Work Trees\\Example Repo"; + const result = await ensureSourceRegistered("gstack-code-foo", sourcePath, { + federated: true, + env: fake.env, + }); + expect(result.changed).toBe(true); + + const state = JSON.parse(readFileSync(fake.statePath, "utf-8")) as { + sources: Array<{ local_path?: string }>; + }; + expect(state.sources[0]?.local_path).toBe(sourcePath); + fake.cleanup(); + }); }); describe("sourcePageCount", () => { diff --git a/test/gbrain-spawn-windows-shell.test.ts b/test/gbrain-spawn-windows-shell.test.ts index d968d2f68..6a93f80f8 100644 --- a/test/gbrain-spawn-windows-shell.test.ts +++ b/test/gbrain-spawn-windows-shell.test.ts @@ -5,34 +5,29 @@ 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", () => { +// #1731 + DEV-206 tripwire. Bash helpers still need a shell on Windows, but +// gbrain itself must resolve to Bun's direct executable shim so argv values +// (notably worktree paths containing spaces) never pass through cmd.exe. +describe("Windows gbrain spawns preserve structural argv", () => { 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("gbrain-exec resolves a direct executable and disables shell parsing", () => { + const src = read("lib/gbrain-exec.ts"); + expect(src).toContain('Bun.which("gbrain"'); + expect(src).toContain("planGbrainSpawn"); + expect(src).toContain("return { command, args: [...args], shell: false }"); + expect(src).not.toMatch(/(spawnSync|spawn|execFileSync)\("gbrain"/); + }); + + test("gbrain-sources routes registration through the structural helper", () => { + const src = read("lib/gbrain-sources.ts"); + expect(src).toContain("spawnGbrain(addArgs"); + expect(src).toContain('spawnGbrain(["sources", "remove"'); + expect(src).not.toContain('spawnSync("gbrain"'); + }); test("orchestrator brain-sync spawns carry the Windows shell flag", () => { const src = read("bin/gstack-gbrain-sync.ts"); diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 19a9bac4e..a709d8572 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { delimiter, join } from "path"; import { spawnSync } from "child_process"; import { @@ -247,10 +247,8 @@ describe("gstack-gbrain-sync CLI", () => { // shim on PATH so the stage runs (we only assert the preview line, never // invoke gbrain itself). const bindir = mkdtempSync(join(tmpdir(), "gstack-host-collide-bin-")); - const shim = join(bindir, "gbrain"); - writeFileSync(shim, "#!/bin/sh\nexit 0\n"); - chmodSync(shim, 0o755); - const PATH = `${bindir}:${process.env.PATH || ""}`; + makeShim(bindir, { "--help": { stdout: "gbrain\n" } }); + const PATH = `${bindir}${delimiter}${process.env.PATH || ""}`; const runAs = (host: string) => spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { @@ -563,19 +561,27 @@ describe("gstack-gbrain-sync CLI", () => { * (which spawn `gbrain` from PATH) without a real gbrain CLI. */ function makeShim(bindir: string, responses: Record): string { - const shim = join(bindir, "gbrain"); - const cases = Object.entries(responses).map(([key, r]) => { - const exit = r.exit ?? 0; - const stdout = (r.stdout || "").replace(/'/g, "'\\''"); - const stderr = (r.stderr || "").replace(/'/g, "'\\''"); - // Patterns with spaces MUST be double-quoted in sh case statements, - // otherwise the shell parses the second word as the start of the next - // pattern and errors out. - return ` "${key}") printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit ${exit} ;;`; - }).join("\n"); - // Match on the full argument string, joined with literal spaces. - const script = `#!/bin/sh\nARGS="$*"\ncase "$ARGS" in\n${cases}\n *) echo "shim: no match for [$ARGS]" >&2; exit 1 ;;\nesac\n`; - writeFileSync(shim, script); + const source = join(bindir, "gbrain-shim.ts"); + const shim = join(bindir, process.platform === "win32" ? "gbrain.exe" : "gbrain"); + const script = ` +const responses = ${JSON.stringify(responses)} as Record; +const args = Bun.argv.slice(2).join(" "); +const response = responses[args]; +if (!response) { + process.stderr.write(\`shim: no match for [\${args}]\\n\`); + process.exit(1); +} +if (response.stdout) process.stdout.write(response.stdout); +if (response.stderr) process.stderr.write(response.stderr); +process.exit(response.exit ?? 0); +`; + writeFileSync(source, script); + const built = spawnSync(process.execPath, ["build", source, "--compile", "--outfile", shim], { + encoding: "utf-8", + }); + if (built.status !== 0) { + throw new Error(`failed to compile gbrain shim: ${built.stderr || built.stdout}`); + } chmodSync(shim, 0o755); return shim; } @@ -631,7 +637,7 @@ describe("derivePathOnlyHashLegacyId", () => { encoding: "utf-8", timeout: 60000, cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}:${process.env.PATH || ""}` }, + env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}${delimiter}${process.env.PATH || ""}` }, }); const newId = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; expect(newId).toBeTruthy(); @@ -652,7 +658,7 @@ describe("derivePathOnlyHashLegacyId", () => { * explicitly to each spawn for the override to take effect. */ function envWithBindir(bindir: string): NodeJS.ProcessEnv { - return { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` }; + return { ...process.env, PATH: `${bindir}${delimiter}${process.env.PATH || ""}` }; } describe("planHostnameFoldMigration", () => {