fix(autopilot): resolve gbrain CLI on Windows via %PATH% enumeration (#3832)

Wave-assembled from PR #3832 by @veltri-23.

Co-Authored-By: Hunter Veltri <veltrifinancial@gmail.com>
This commit is contained in:
Garry Tan
2026-08-12 14:38:36 -07:00
committed by Sina Matian
co-authored by Hunter Veltri
parent 810d1c5540
commit d8e3772810
2 changed files with 209 additions and 24 deletions
+68 -23
View File
@@ -19,7 +19,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync, statSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { join, dirname } from 'path';
import { join, dirname, isAbsolute } from 'path';
import { execSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadPreferences } from '../core/preferences.ts';
@@ -139,41 +139,87 @@ function logError(phase: string, e: unknown) {
} catch { /* best-effort */ }
}
/**
* Enumerate %PATH% (Windows) for the gbrain CLI shim, honoring PATHEXT.
*
* On win32 this is the FIRST resolution path (`which` does not exist in
* cmd/PowerShell); resolveGbrainCliPath calls it before the execPath and
* argv[1] fallbacks. Unlike `where`, this NEVER looks at the current
* directory, so a stray gbrain.exe in cwd cannot hijack resolution. Only
* directly spawnable extensions (.exe/.com/.cmd/.bat) are accepted, and
* only regular files - a directory named gbrain.exe cannot shadow a real
* binary. Returns the first existing candidate, or '' when none exists.
*/
export function resolveWindowsCliPath(): string {
const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
const pathDirs = (process.env.PATH ?? '').split(';');
for (const dir of pathDirs) {
// Skip empty and relative entries: '.' or 'bin' resolve against the
// current directory, which would reintroduce the cwd-hijack `where`
// has. Only absolute %PATH% entries are trusted.
if (!dir || !isAbsolute(dir)) continue;
for (const ext of pathext) {
// Only directly spawnable types: PATHEXT can also carry .JS/.VBS
// (Windows Script Host), which Bun cannot exec - spawning them fails
// EFTYPE. .CMD/.BAT spawn through the shell; .COM/.EXE direct.
const type = ext.toLowerCase();
if (type !== '.exe' && type !== '.com' && type !== '.cmd' && type !== '.bat') continue;
const candidate = join(dir, 'gbrain' + type);
try {
if (statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable - keep looking */ }
}
}
return '';
}
/**
* Resolve the gbrain CLI entrypoint for spawning the worker child.
*
* A .ts source path is never a valid spawn target spawning it fails with
* A .ts source path is never a valid spawn target - spawning it fails with
* EACCES because TypeScript source isn't executable. The canonical install
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
*
* Order of resolution:
* 1. `which gbrain` — the shim on PATH, canonical for installed builds.
* 1. Platform PATH lookup - `which gbrain` on POSIX; explicit %PATH%
* enumeration (resolveWindowsCliPath) on win32, where `which` does
* not exist (#3793).
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
* binary without PATH). Never .ts source paths.
* 4. Throw with a clear install hint.
*/
export function resolveGbrainCliPath(): string {
try {
// #2747: `env: process.env` is required under Bun. Bun's execSync
// snapshots process.env at Bun's OWN startup, not at call time — a
// runtime PATH mutation (dotenv/config loading, shell-profile sourcing
// in a wrapper, etc.) happening between Bun boot and this call is
// invisible to `which` without explicitly forwarding the current env.
// This is why "which gbrain" succeeds when run standalone (fresh Bun
// process, no prior mutation) but can fail from inside autopilot's own
// process at this exact call site. Same fix already applied to
// detectTini() in spawn-helpers.ts (see its comment) — this call site
// was missed.
const which = execSync('which gbrain', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
env: process.env,
}).trim();
if (which) return which;
} catch { /* not on $PATH — fall through */ }
// #3793: `which` does not exist in cmd or PowerShell on Windows, so the
// bun-installed gbrain.exe shim on %PATH% was never found and autopilot
// died with "Could not resolve the gbrain CLI path". `where` would find
// it but has a cwd-hijack; use explicit %PATH% enumeration on win32.
if (process.platform === 'win32') {
const win = resolveWindowsCliPath();
if (win) return win;
} else {
try {
// #2747: `env: process.env` is required under Bun. Bun's execSync
// snapshots process.env at Bun's OWN startup, not at call time - a
// runtime PATH mutation (dotenv/config loading, shell-profile sourcing
// in a wrapper, etc.) happening between Bun boot and this call is
// invisible to `which` without explicitly forwarding the current env.
// This is why "which gbrain" succeeds when run standalone (fresh Bun
// process, no prior mutation) but can fail from inside autopilot's own
// process at this exact call site. Same fix already applied to
// detectTini() in spawn-helpers.ts (see its comment) - this call site
// was missed.
const which = execSync('which gbrain', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
env: process.env,
})
.trim()
.split(/\r?\n/, 1)[0];
if (which) return which;
} catch { /* not on $PATH - fall through */ }
}
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
return exec;
@@ -193,7 +239,6 @@ export function resolveGbrainCliPath(): string {
`Debug: PATH=${JSON.stringify(process.env.PATH ?? '')} execPath=${JSON.stringify(exec)} argv1=${JSON.stringify(arg1)}`,
);
}
export function shouldSpawnAutopilotWorker(args: string[]): boolean {
return !args.includes('--no-worker');
}
+141 -1
View File
@@ -10,7 +10,11 @@
*/
import { describe, test, expect } from 'bun:test';
import { resolveGbrainCliPath } from '../src/commands/autopilot.ts';
import { resolveGbrainCliPath, resolveWindowsCliPath } from '../src/commands/autopilot.ts';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
describe('resolveGbrainCliPath', () => {
test('returns a non-empty string or throws with a clear install hint', () => {
@@ -81,3 +85,139 @@ describe('resolveGbrainCliPath', () => {
}
});
});
describe('resolveWindowsCliPath', () => {
function withFakePath(dirs: string[], fn: () => Promise<void>) {
// withEnv is the repo's canonical env isolation pattern (R1); bare
// process.env mutation in a non-serial file trips the isolation lint.
return withEnv({ PATH: dirs.join(';'), PATHEXT: '.EXE;.CMD' }, fn);
}
test('finds gbrain.exe on %PATH% (issue #3793)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-path-'));
try {
writeFileSync(join(dir, 'gbrain.exe'), 'fake');
await withFakePath([dir], async () => {
const result = resolveWindowsCliPath();
expect(result).toBe(join(dir, 'gbrain.exe'));
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('NEVER resolves from the current directory (cwd-hijack guard)', async () => {
// `where` would search cwd before %PATH% and pick up a stray
// gbrain.exe dropped there. Our enumeration must not.
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-cwd-'));
const pathDir = mkdtempSync(join(tmpdir(), 'gbrain-path2-'));
const origCwd = process.cwd();
try {
writeFileSync(join(cwd, 'gbrain.exe'), 'evil');
// pathDir has no gbrain.exe — nothing legit to find.
process.chdir(cwd);
await withFakePath([pathDir], async () => {
expect(resolveWindowsCliPath()).toBe('');
});
// And a legit candidate on PATH wins even when cwd has one too.
writeFileSync(join(pathDir, 'gbrain.exe'), 'real');
await withFakePath([pathDir], async () => {
expect(resolveWindowsCliPath()).toBe(join(pathDir, 'gbrain.exe'));
});
} finally {
process.chdir(origCwd);
rmSync(cwd, { recursive: true, force: true });
rmSync(pathDir, { recursive: true, force: true });
}
});
test('honors PATHEXT when .exe is absent', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-cmd-'));
try {
writeFileSync(join(dir, 'gbrain.cmd'), 'fake');
await withFakePath([dir], async () => {
expect(resolveWindowsCliPath()).toBe(join(dir, 'gbrain.cmd'));
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('returns empty string when nothing matches', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-empty-'));
try {
await withFakePath([dir], async () => {
expect(resolveWindowsCliPath()).toBe('');
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('ignores relative %PATH% entries (cwd-hijack guard, round 2)', async () => {
// A PATH entry like '.' or 'bin' resolves against cwd. `where` would
// pick up a gbrain.exe there; explicit enumeration must skip non-
// absolute entries entirely.
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-cwd2-'));
const absDir = mkdtempSync(join(tmpdir(), 'gbrain-abs-'));
const origCwd = process.cwd();
try {
writeFileSync(join(cwd, 'gbrain.exe'), 'evil');
writeFileSync(join(absDir, 'gbrain.exe'), 'real');
process.chdir(cwd);
// Only relative entries: must NOT resolve the cwd copy.
await withFakePath(['.', 'bin'], async () => {
expect(resolveWindowsCliPath()).toBe('');
});
// Absolute entry wins even when a relative one appears first.
await withFakePath(['.', absDir], async () => {
expect(resolveWindowsCliPath()).toBe(join(absDir, 'gbrain.exe'));
});
} finally {
process.chdir(origCwd);
rmSync(cwd, { recursive: true, force: true });
rmSync(absDir, { recursive: true, force: true });
}
});
test('skips non-spawnable PATHEXT types like .js (EFTYPE guard)', async () => {
// PATHEXT can carry .JS/.VBS (Windows Script Host); Bun cannot exec
// those directly. A gbrain.js must never be picked over nothing.
const dir = mkdtempSync(join(tmpdir(), 'gbrain-js-'));
try {
writeFileSync(join(dir, 'gbrain.js'), 'evil');
await withEnv(
{ PATH: dir, PATHEXT: '.JS;.EXE' },
async () => {
expect(resolveWindowsCliPath()).toBe('');
},
);
// But a real .exe in the same dir still resolves.
writeFileSync(join(dir, 'gbrain.exe'), 'real');
await withEnv(
{ PATH: dir, PATHEXT: '.JS;.EXE' },
async () => {
expect(resolveWindowsCliPath()).toBe(join(dir, 'gbrain.exe'));
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('a directory named gbrain.exe cannot shadow a real binary', async () => {
// statSync().isFile() guard: a folder that happens to be named
// gbrain.exe must not be returned as the CLI.
const dir = mkdtempSync(join(tmpdir(), 'gbrain-dirshadow-'));
try {
mkdirSync(join(dir, 'gbrain.exe'));
await withEnv({ PATH: dir, PATHEXT: '.EXE' }, async () => {
expect(resolveWindowsCliPath()).toBe('');
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});