From df9f7b69c9a172df19f5e7878dca7a407ed31417 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 27 Apr 2026 23:01:31 -0700 Subject: [PATCH] feat(claude-bin): Bun.which wrapper for cross-platform claude resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 75 LOC of fork-side reimplementation (PATH parsing, Windows PATHEXT, case-insensitive Path/PATH, X_OK) with a thin wrapper around Bun.which() — the runtime built-in that already does all of it. New file is ~70 LOC including the override + arg-prefix logic the runtime doesn't cover. Override branch fixed: GSTACK_CLAUDE_BIN=wsl now resolves through Bun.which() just like a bare claude lookup would. The McGluut fork's claude-bin.ts only handled absolute-path overrides; bare commands silently returned null. Passing the override value through Bun.which fixes the documented use case for free. Five hardcoded claude spawn sites rewired through resolveClaudeCommand: - browse/src/security-classifier.ts:396 — version probe - browse/src/security-classifier.ts:496 — Haiku transcript classifier - scripts/preflight-agent-sdk.ts — preflight binary pinning - test/helpers/providers/claude.ts — LLM judge availability + run - test/helpers/agent-sdk-runner.ts — SDK harness binary resolver All retain their existing degrade-on-missing semantics. Tests: browse/test/claude-bin.test.ts has 9 unit tests including the override-PATH-resolution case the fork's version got wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/claude-bin.ts | 73 +++++++++++++++++++++++++ browse/src/security-classifier.ts | 15 ++++- browse/test/claude-bin.test.ts | 91 +++++++++++++++++++++++++++++++ scripts/preflight-agent-sdk.ts | 11 ++-- test/helpers/agent-sdk-runner.ts | 8 +-- test/helpers/providers/claude.ts | 20 ++++--- 6 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 browse/src/claude-bin.ts create mode 100644 browse/test/claude-bin.test.ts diff --git a/browse/src/claude-bin.ts b/browse/src/claude-bin.ts new file mode 100644 index 00000000..ff413d33 --- /dev/null +++ b/browse/src/claude-bin.ts @@ -0,0 +1,73 @@ +/** + * claude-bin.ts — Cross-platform `claude` binary resolution. + * + * Uses Bun.which() for the platform handling (PATH parsing, Windows PATHEXT, + * X_OK, case-insensitive Path/PATH on Windows). Adds the gstack-specific + * override + arg-prefix logic on top. + * + * Override precedence: + * 1. GSTACK_CLAUDE_BIN (or CLAUDE_BIN as fallback) — absolute path or + * PATH-resolvable command. `wsl` resolves through Bun.which('wsl') just + * like a bare `claude` lookup would. + * 2. Plain `Bun.which('claude')` if no override is set. + * + * Arg prefix: + * GSTACK_CLAUDE_BIN_ARGS (or CLAUDE_BIN_ARGS) prepends arguments to every + * spawn. Accepts a JSON array (e.g. '["claude", "--no-cache"]') or a single + * scalar string treated as one argument. Only applied when an override is + * active — bare `claude` resolution doesn't pick up an arg prefix. + * + * Returns null when nothing resolves; callers should degrade (e.g. transcript + * classifier returns degraded:true) rather than throw. + */ + +import * as path from 'path'; + +export interface ClaudeCommand { + command: string; + argsPrefix: string[]; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"(.*)"$/, '$1'); +} + +function parseOverrideArgs(env: NodeJS.ProcessEnv): string[] { + const raw = env.GSTACK_CLAUDE_BIN_ARGS ?? env.CLAUDE_BIN_ARGS; + if (!raw?.trim()) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) { + return parsed; + } + } catch { + // Not JSON — treat as a single scalar argument. + } + return [stripWrappingQuotes(raw.trim())]; +} + +export function resolveClaudeCommand( + env: NodeJS.ProcessEnv = process.env, +): ClaudeCommand | null { + const argsPrefix = parseOverrideArgs(env); + const override = (env.GSTACK_CLAUDE_BIN ?? env.CLAUDE_BIN)?.trim(); + // Honor case-insensitive Path/PATH on Windows. Bun.which itself reads + // process.env so we forward whichever the caller passed. + const PATH = env.PATH ?? env.Path ?? ''; + + if (override) { + const trimmed = stripWrappingQuotes(override); + // Absolute path: use as-is. Otherwise PATH-resolve through Bun.which so + // overrides like GSTACK_CLAUDE_BIN=wsl find the actual binary. + const resolved = path.isAbsolute(trimmed) ? trimmed : Bun.which(trimmed, { PATH }); + return resolved ? { command: resolved, argsPrefix } : null; + } + + const command = Bun.which('claude', { PATH }); + return command ? { command, argsPrefix: [] } : null; +} + +/** Convenience wrapper for callers that only need the command path. */ +export function resolveClaudeBinary(env: NodeJS.ProcessEnv = process.env): string | null { + return resolveClaudeCommand(env)?.command ?? null; +} diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts index b96f8aae..d631df50 100644 --- a/browse/src/security-classifier.ts +++ b/browse/src/security-classifier.ts @@ -30,6 +30,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { THRESHOLDS, type LayerSignal } from './security'; +import { resolveClaudeCommand } from './claude-bin'; /** * Pinned Haiku model for the transcript classifier. Bumped deliberately when a @@ -392,8 +393,13 @@ let haikuAvailableCache: boolean | null = null; function checkHaikuAvailable(): Promise { if (haikuAvailableCache !== null) return Promise.resolve(haikuAvailableCache); + const claude = resolveClaudeCommand(); + if (!claude) { + haikuAvailableCache = false; + return Promise.resolve(false); + } return new Promise((resolve) => { - const p = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }); + const p = spawn(claude.command, [...claude.argsPrefix, '--version'], { stdio: ['ignore', 'pipe', 'pipe'] }); let done = false; const finish = (ok: boolean) => { if (done) return; @@ -493,7 +499,12 @@ export async function checkTranscript(params: { // timeout rate in the v1.5.2.0 ensemble bench because of this, plus // ~44k cache_creation tokens per call (massive cost inflation). // Using os.tmpdir() gives Haiku a clean context for pure classification. - const p = spawn('claude', [ + const claude = resolveClaudeCommand(); + if (!claude) { + return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } }); + } + const p = spawn(claude.command, [ + ...claude.argsPrefix, '-p', prompt, '--model', HAIKU_MODEL, '--output-format', 'json', diff --git a/browse/test/claude-bin.test.ts b/browse/test/claude-bin.test.ts new file mode 100644 index 00000000..25aee32c --- /dev/null +++ b/browse/test/claude-bin.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'bun:test'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { resolveClaudeCommand, resolveClaudeBinary } from '../src/claude-bin'; + +// Empty env baseline — no PATH, no overrides — ensures no environmental claude binary leaks in. +const EMPTY_ENV = { PATH: '', Path: '' } as NodeJS.ProcessEnv; + +describe('claude-bin', () => { + test('no override, no PATH match → returns null', () => { + expect(resolveClaudeCommand(EMPTY_ENV)).toBeNull(); + expect(resolveClaudeBinary(EMPTY_ENV)).toBeNull(); + }); + + test('absolute-path override returned as-is', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: '/opt/custom/claude', + }); + expect(got).toEqual({ command: '/opt/custom/claude', argsPrefix: [] }); + }); + + test('CLAUDE_BIN works as fallback alias for GSTACK_CLAUDE_BIN', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + CLAUDE_BIN: '/opt/custom/claude', + }); + expect(got?.command).toBe('/opt/custom/claude'); + }); + + test('GSTACK_CLAUDE_BIN takes precedence over CLAUDE_BIN', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: '/explicit/path', + CLAUDE_BIN: '/fallback/path', + }); + expect(got?.command).toBe('/explicit/path'); + }); + + test('PATH-resolvable override goes through Bun.which (the bug the fork shipped)', () => { + // Make a fake binary in a temp dir, point PATH at it, set override to bare command name. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-bin-test-')); + const fakeBin = path.join(tmpDir, 'fake-claude-cli'); + fs.writeFileSync(fakeBin, '#!/bin/sh\necho fake\n'); + fs.chmodSync(fakeBin, 0o755); + try { + const got = resolveClaudeCommand({ + PATH: tmpDir, + GSTACK_CLAUDE_BIN: 'fake-claude-cli', + }); + expect(got?.command).toBe(fakeBin); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('override pointing at missing binary → null (no silent fallback to bare claude)', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: 'definitely-not-a-real-binary-xyz', + }); + expect(got).toBeNull(); + }); + + test('GSTACK_CLAUDE_BIN_ARGS as JSON array → parsed argsPrefix', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: '/opt/custom/claude', + GSTACK_CLAUDE_BIN_ARGS: '["--no-cache", "--verbose"]', + }); + expect(got?.argsPrefix).toEqual(['--no-cache', '--verbose']); + }); + + test('GSTACK_CLAUDE_BIN_ARGS as scalar string → treated as single argument', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: '/opt/custom/claude', + GSTACK_CLAUDE_BIN_ARGS: 'claude', + }); + expect(got?.argsPrefix).toEqual(['claude']); + }); + + test('argsPrefix empty when no override args set', () => { + const got = resolveClaudeCommand({ + ...EMPTY_ENV, + GSTACK_CLAUDE_BIN: '/opt/custom/claude', + }); + expect(got?.argsPrefix).toEqual([]); + }); +}); diff --git a/scripts/preflight-agent-sdk.ts b/scripts/preflight-agent-sdk.ts index c437e5e4..8a0bc561 100644 --- a/scripts/preflight-agent-sdk.ts +++ b/scripts/preflight-agent-sdk.ts @@ -18,7 +18,7 @@ import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { readOverlay } from './resolvers/model-overlay'; -import { execSync } from 'child_process'; +import { resolveClaudeBinary } from '../browse/src/claude-bin'; async function main() { const failures: string[] = []; @@ -44,12 +44,11 @@ async function main() { // 2. Local claude binary exists console.log('\n2. Binary pinning'); - let claudePath: string | null = null; - try { - claudePath = execSync('which claude', { encoding: 'utf-8' }).trim(); + let claudePath: string | null = resolveClaudeBinary(); + if (claudePath) { pass(`local claude binary: ${claudePath}`); - } catch { - fail('`which claude` failed — cannot pin binary'); + } else { + fail('`Bun.which("claude")` failed — cannot pin binary (set GSTACK_CLAUDE_BIN to override)'); } // 3. SDK query end-to-end diff --git a/test/helpers/agent-sdk-runner.ts b/test/helpers/agent-sdk-runner.ts index cea7bf76..ce4512bf 100644 --- a/test/helpers/agent-sdk-runner.ts +++ b/test/helpers/agent-sdk-runner.ts @@ -35,7 +35,7 @@ import { } from '@anthropic-ai/claude-agent-sdk'; import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; +import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../browse/src/claude-bin'; import type { SkillTestResult } from './session-runner'; // --------------------------------------------------------------------------- @@ -278,11 +278,7 @@ function resolveSdkVersion(): string { } export function resolveClaudeBinary(): string | null { - try { - return execSync('which claude', { encoding: 'utf-8' }).trim() || null; - } catch { - return null; - } + return resolveClaudeBinaryShared(); } // --------------------------------------------------------------------------- diff --git a/test/helpers/providers/claude.ts b/test/helpers/providers/claude.ts index 837d9667..5e3c1acb 100644 --- a/test/helpers/providers/claude.ts +++ b/test/helpers/providers/claude.ts @@ -1,9 +1,10 @@ import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types'; import { estimateCostUsd } from '../pricing'; -import { execFileSync, spawnSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { resolveClaudeCommand } from '../../../browse/src/claude-bin'; /** * Claude adapter — wraps the `claude` CLI via claude -p. @@ -18,10 +19,11 @@ export class ClaudeAdapter implements ProviderAdapter { readonly family = 'claude' as const; async available(): Promise { - // Binary on PATH? - const res = spawnSync('sh', ['-c', 'command -v claude'], { timeout: 2000 }); - if (res.status !== 0) { - return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code' }; + // Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared + // resolver so Windows + override paths behave the same as production sites. + const resolved = resolveClaudeCommand(); + if (!resolved) { + return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' }; } // Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY const credsPath = path.join(os.homedir(), '.claude', '.credentials.json'); @@ -35,12 +37,16 @@ export class ClaudeAdapter implements ProviderAdapter { async run(opts: RunOpts): Promise { const start = Date.now(); - const args = ['-p', '--output-format', 'json']; + const resolved = resolveClaudeCommand(); + if (!resolved) { + throw new Error('claude CLI not resolvable (set GSTACK_CLAUDE_BIN or install)'); + } + const args = [...resolved.argsPrefix, '-p', '--output-format', 'json']; if (opts.model) args.push('--model', opts.model); if (opts.extraArgs) args.push(...opts.extraArgs); try { - const out = execFileSync('claude', args, { + const out = execFileSync(resolved.command, args, { input: opts.prompt, cwd: opts.workdir, timeout: opts.timeoutMs,