fix(browse): bash.exe wrap for telemetry on Windows

reportAttemptTelemetry() in browse/src/security.ts calls spawn(bin, args)
where bin is the gstack-telemetry-log bash script. On Windows this fails
silently with ENOENT — CreateProcess can't dispatch on shebang lines.

Adopts v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (from
browse/src/claude-bin.ts:resolveClaudeCommand, introduced in #1252) for
resolving bash.exe. resolveBashBinary() honors GSTACK_BASH_BIN absolute-path
or PATH-resolvable override, falling back to Bun.which('bash') which finds
Git Bash on the standard Windows install.

buildTelemetrySpawnCommand() wraps the script invocation on win32 only;
POSIX path is bit-identical. Returns null when bash can't be resolved on
Windows so caller skips spawn — local attempts.jsonl audit trail keeps
working without surfacing a Windows-only failure.

8 new unit tests cover resolveBashBinary (POSIX bash, absolute override,
quote-stripping, BASH_BIN fallback, empty-PATH null) and buildTelemetrySpawnCommand
(POSIX pass-through, win32 bash wrap, win32 null on unresolvable, arg-array
immutability).

POSIX path is bit-identical — Bun.which('bash') on Linux/macOS returns the
same /bin/bash or /usr/bin/bash that the old hardcoded spawn relied on.
This commit is contained in:
Samuel Carson
2026-05-03 15:46:04 -05:00
parent bf65487162
commit 468e94dc55
2 changed files with 135 additions and 2 deletions
+76
View File
@@ -20,6 +20,8 @@ import {
readSessionState,
getStatus,
extractDomain,
buildTelemetrySpawnCommand,
resolveBashBinary,
type LayerSignal,
} from '../src/security';
@@ -325,3 +327,77 @@ describe('extractDomain', () => {
expect(extractDomain('')).toBe('');
});
});
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
describe('resolveBashBinary', () => {
test('on POSIX, returns the system bash via Bun.which', () => {
if (process.platform === 'win32') return;
const out = resolveBashBinary({ PATH: process.env.PATH ?? '' });
expect(out).toBeTruthy();
expect(out!.endsWith('bash')).toBe(true);
});
test('honors GSTACK_BASH_BIN absolute-path override', () => {
// Construct a synthetic absolute path; the helper short-circuits on
// path.isAbsolute and never touches the filesystem, so this is portable.
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ GSTACK_BASH_BIN: fake, PATH: '' });
expect(out).toBe(fake);
});
test('strips wrapping double quotes from override values', () => {
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ GSTACK_BASH_BIN: `"${fake}"`, PATH: '' });
expect(out).toBe(fake);
});
test('BASH_BIN works as a fallback when GSTACK_BASH_BIN is unset', () => {
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ BASH_BIN: fake, PATH: '' });
expect(out).toBe(fake);
});
test('returns null when nothing resolves (override is unset and PATH is empty)', () => {
// Empty PATH means Bun.which finds nothing.
const out = resolveBashBinary({ PATH: '' });
expect(out).toBeNull();
});
});
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
describe('buildTelemetrySpawnCommand', () => {
const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log';
const args = ['--event-type', 'attack_attempt', '--confidence', '0.95'];
test('on POSIX, returns the binary path and args unchanged', () => {
if (process.platform === 'win32') return;
const out = buildTelemetrySpawnCommand(bin, args);
expect(out).not.toBeNull();
expect(out!.cmd).toBe(bin);
expect(out!.cmdArgs).toEqual(args);
});
test('on win32 with bash resolvable, wraps the call in bash with the script as first arg', () => {
if (process.platform !== 'win32') return;
const fakeBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
const out = buildTelemetrySpawnCommand(bin, args, { GSTACK_BASH_BIN: fakeBash, PATH: '' });
expect(out).not.toBeNull();
expect(out!.cmd).toBe(fakeBash);
expect(out!.cmdArgs).toEqual([bin, ...args]);
});
test('on win32 with bash unresolvable, returns null so caller skips spawn', () => {
if (process.platform !== 'win32') return;
// No override, empty PATH — Bun.which finds nothing on Windows.
const out = buildTelemetrySpawnCommand(bin, args, { PATH: '' });
expect(out).toBeNull();
});
test('does not mutate the caller-supplied args array', () => {
const originalArgs = [...args];
buildTelemetrySpawnCommand(bin, args);
expect(args).toEqual(originalArgs);
});
});