mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-05 13:58:37 +02:00
feat(claude-bin): Bun.which wrapper for cross-platform claude resolution
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d9f17c2394
commit
df9f7b69c9
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { THRESHOLDS, type LayerSignal } from './security';
|
import { THRESHOLDS, type LayerSignal } from './security';
|
||||||
|
import { resolveClaudeCommand } from './claude-bin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pinned Haiku model for the transcript classifier. Bumped deliberately when a
|
* Pinned Haiku model for the transcript classifier. Bumped deliberately when a
|
||||||
@@ -392,8 +393,13 @@ let haikuAvailableCache: boolean | null = null;
|
|||||||
|
|
||||||
function checkHaikuAvailable(): Promise<boolean> {
|
function checkHaikuAvailable(): Promise<boolean> {
|
||||||
if (haikuAvailableCache !== null) return Promise.resolve(haikuAvailableCache);
|
if (haikuAvailableCache !== null) return Promise.resolve(haikuAvailableCache);
|
||||||
|
const claude = resolveClaudeCommand();
|
||||||
|
if (!claude) {
|
||||||
|
haikuAvailableCache = false;
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
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;
|
let done = false;
|
||||||
const finish = (ok: boolean) => {
|
const finish = (ok: boolean) => {
|
||||||
if (done) return;
|
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
|
// timeout rate in the v1.5.2.0 ensemble bench because of this, plus
|
||||||
// ~44k cache_creation tokens per call (massive cost inflation).
|
// ~44k cache_creation tokens per call (massive cost inflation).
|
||||||
// Using os.tmpdir() gives Haiku a clean context for pure classification.
|
// 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,
|
'-p', prompt,
|
||||||
'--model', HAIKU_MODEL,
|
'--model', HAIKU_MODEL,
|
||||||
'--output-format', 'json',
|
'--output-format', 'json',
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { readOverlay } from './resolvers/model-overlay';
|
import { readOverlay } from './resolvers/model-overlay';
|
||||||
import { execSync } from 'child_process';
|
import { resolveClaudeBinary } from '../browse/src/claude-bin';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
@@ -44,12 +44,11 @@ async function main() {
|
|||||||
|
|
||||||
// 2. Local claude binary exists
|
// 2. Local claude binary exists
|
||||||
console.log('\n2. Binary pinning');
|
console.log('\n2. Binary pinning');
|
||||||
let claudePath: string | null = null;
|
let claudePath: string | null = resolveClaudeBinary();
|
||||||
try {
|
if (claudePath) {
|
||||||
claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
||||||
pass(`local claude binary: ${claudePath}`);
|
pass(`local claude binary: ${claudePath}`);
|
||||||
} catch {
|
} else {
|
||||||
fail('`which claude` failed — cannot pin binary');
|
fail('`Bun.which("claude")` failed — cannot pin binary (set GSTACK_CLAUDE_BIN to override)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. SDK query end-to-end
|
// 3. SDK query end-to-end
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from '@anthropic-ai/claude-agent-sdk';
|
} from '@anthropic-ai/claude-agent-sdk';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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';
|
import type { SkillTestResult } from './session-runner';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -278,11 +278,7 @@ function resolveSdkVersion(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveClaudeBinary(): string | null {
|
export function resolveClaudeBinary(): string | null {
|
||||||
try {
|
return resolveClaudeBinaryShared();
|
||||||
return execSync('which claude', { encoding: 'utf-8' }).trim() || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
|
||||||
import { estimateCostUsd } from '../pricing';
|
import { estimateCostUsd } from '../pricing';
|
||||||
import { execFileSync, spawnSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { resolveClaudeCommand } from '../../../browse/src/claude-bin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude adapter — wraps the `claude` CLI via claude -p.
|
* Claude adapter — wraps the `claude` CLI via claude -p.
|
||||||
@@ -18,10 +19,11 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|||||||
readonly family = 'claude' as const;
|
readonly family = 'claude' as const;
|
||||||
|
|
||||||
async available(): Promise<AvailabilityCheck> {
|
async available(): Promise<AvailabilityCheck> {
|
||||||
// Binary on PATH?
|
// Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared
|
||||||
const res = spawnSync('sh', ['-c', 'command -v claude'], { timeout: 2000 });
|
// resolver so Windows + override paths behave the same as production sites.
|
||||||
if (res.status !== 0) {
|
const resolved = resolveClaudeCommand();
|
||||||
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code' };
|
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
|
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
|
||||||
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||||
@@ -35,12 +37,16 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|||||||
|
|
||||||
async run(opts: RunOpts): Promise<RunResult> {
|
async run(opts: RunOpts): Promise<RunResult> {
|
||||||
const start = Date.now();
|
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.model) args.push('--model', opts.model);
|
||||||
if (opts.extraArgs) args.push(...opts.extraArgs);
|
if (opts.extraArgs) args.push(...opts.extraArgs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = execFileSync('claude', args, {
|
const out = execFileSync(resolved.command, args, {
|
||||||
input: opts.prompt,
|
input: opts.prompt,
|
||||||
cwd: opts.workdir,
|
cwd: opts.workdir,
|
||||||
timeout: opts.timeoutMs,
|
timeout: opts.timeoutMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user