mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
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:
committed by
Sina Matian
co-authored by
Hunter Veltri
parent
810d1c5540
commit
d8e3772810
+68
-23
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user