This commit is contained in:
Satria Suria 2026-07-15 15:28:59 +08:00 committed by GitHub
commit a7febeb6a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 628 additions and 207 deletions

View File

@ -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.**

View File

@ -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:

View File

@ -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-<pid>-<ts>/` 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 min24h). 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

View File

@ -1 +1 @@
1.60.1.0
1.60.2.0

View File

@ -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 <id>` 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<StageResult> {
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<StageResult> {
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<StageResult> {
DEFAULT_DREAM_TIMEOUT_MS,
);
// Scope the cycle to THIS worktree's code source: `gbrain dream --source <id>`.
// 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 <id>` 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<StageResult> {
}
let result: ReturnType<typeof spawnGbrain>;
try {
result = spawnGbrain(dreamArgs, {
result = spawnGbrain(dreamPlan.args, {
stdio: ["ignore", "pipe", "pipe"],
timeout: dreamTimeoutMs,
baseEnv: process.env,

View File

@ -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: {

View File

@ -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<string> {
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,
});
}

View File

@ -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<unknown>(["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;
}
/**

View File

@ -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",

5
setup
View File

@ -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

View File

@ -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 <id>]",
);
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 });
}
});
});

View File

@ -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";

View File

@ -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", () => {

View File

@ -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");

View File

@ -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, { stdout?: string; stderr?: string; exit?: number }>): 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<string, { stdout?: string; stderr?: string; exit?: number }>;
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", () => {