diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 69d5a7bdd..2994371ce 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -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'); } diff --git a/test/autopilot-resolve-cli.test.ts b/test/autopilot-resolve-cli.test.ts index 5db41bb52..26569f7b4 100644 --- a/test/autopilot-resolve-cli.test.ts +++ b/test/autopilot-resolve-cli.test.ts @@ -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) { + // 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 }); + } + }); +});