diff --git a/CHANGELOG.md b/CHANGELOG.md index 566160685f..d01a6d47b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Bash and script node failures no longer leak the inline script body into user-visible errors and logs.** When a `bash:` or `script:` DAG node failed, the error string interpolated `err.message` from Node's `ExecFileException`, which begins with `Command failed: bash -c ` (or `bun -e `) — embedding the entire substituted script body. Pino's default error serializer compounded this by writing `err.message`, `err.stack`, and `err.cmd` separately, producing three copies of the body per failure across the CLI, Web UI, and `node_failed` event payload. Diagnostic output (e.g. `Expected ")" but found "x" at [eval]:4:241`) was buried at the end. A new `formatSubprocessFailure()` helper now strips the `Command failed:` prefix line, prefers `stderr` over the message body, tail-caps at 2 KB, and exposes a controlled `{exitCode, killed, stderrTail}` log subset — never the raw error. Timeout / ENOENT / EACCES branches now also log through the sanitized helper, so the body cannot leak via the timeout path either. (#1389) - **Claude provider crashed in dev mode with `error: unknown option '--no-env-file'`.** The Claude Agent SDK switched from shipping `cli.js` to per-platform native binaries (via optional deps) in the 0.2.x series. Archon's `shouldPassNoEnvFile` predicate kept emitting the Bun-only `--no-env-file` flag in dev mode (when the SDK resolves its bundled binary), which the native binary rejects. Tightened the predicate to only emit the flag for explicitly-configured Bun-runnable JS entry points (`.js`/`.mjs`/`.cjs`). Target-repo `.env` isolation is unchanged — `stripCwdEnv()` at process boot remains the primary guard, and the native Claude binary does not auto-load `.env` from its cwd. (#1461) - **Pi structured-output now tolerates reasoning-model prose preamble.** `tryParseStructuredOutput` previously returned `undefined` whenever the assistant text wasn't pure JSON, even when the JSON object was clearly emitted at the end of a "Let me evaluate..." preamble. Reasoning models — observed on Minimax M2.7 — routinely "think out loud" before emitting structured output despite explicit JSON-only prompts. The parser now falls back to a forward-scan from the first `{` when the clean parse fails, recovering the structured output without changing the success path for fully compliant models. (#1440) +- **`CLAUDE_BIN_PATH` is now honored in dev mode.** Previously the env var was silently ignored when running from source (`BUNDLED_IS_BINARY=false`) — `resolveClaudeBinaryPath()` early-returned `undefined` before reading it, leaving glibc Linux contributors with no working escape hatch when the Claude SDK's bundled-binary auto-resolution picked the musl variant first. The env-var check now runs in both modes; config-file path (`assistants.claude.claudeBinaryPath`) remains binary-mode-only since it's a per-repo, not per-machine setting. Env-loading and target-repo `.env` isolation are unchanged — same `stripCwdEnv()` boot-time guard and same `shouldPassNoEnvFile()` predicate run downstream. (#1481) ## [0.3.9] - 2026-04-22 diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index 08993fc8a2..de4004a6ba 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -60,6 +60,8 @@ If neither is set in a compiled binary, Archon throws with install instructions The Claude Agent SDK accepts either the native compiled binary or a JS `cli.js`. +**Dev mode override:** when running from source (`bun run dev:server`), the SDK auto-resolves its bundled per-platform binary by default. Set `CLAUDE_BIN_PATH` if you need to override that — most commonly on glibc Linux where the SDK picks the musl variant first and fails to spawn. Config-file `claudeBinaryPath` is intentionally binary-mode-only (per-repo, not per-machine). + **Typical paths by install method:** | Install method | Typical executable path | diff --git a/packages/providers/src/claude/binary-resolver-dev.test.ts b/packages/providers/src/claude/binary-resolver-dev.test.ts index 2474c76d73..923490fbbd 100644 --- a/packages/providers/src/claude/binary-resolver-dev.test.ts +++ b/packages/providers/src/claude/binary-resolver-dev.test.ts @@ -1,8 +1,15 @@ /** * Tests for the Claude binary resolver in dev mode (BUNDLED_IS_BINARY=false). * Separate file because binary-mode tests mock BUNDLED_IS_BINARY=true. + * + * Dev mode normally lets the SDK resolve the binary from its bundled + * platform package. CLAUDE_BIN_PATH is honored as an escape hatch for + * environments where SDK auto-resolution picks the wrong variant — most + * notably glibc Linux hosts, where the SDK prefers the musl binary first + * and silently falls over with a misleading "not found" error. + * Config-file path is intentionally NOT honored in dev mode (still binary-only). */ -import { describe, test, expect, mock } from 'bun:test'; +import { describe, test, expect, mock, beforeEach, afterAll, spyOn } from 'bun:test'; import { createMockLogger } from '../test/mocks/logger'; mock.module('@archon/paths', () => ({ @@ -10,31 +17,68 @@ mock.module('@archon/paths', () => ({ BUNDLED_IS_BINARY: false, })); -import { resolveClaudeBinaryPath } from './binary-resolver'; +import * as resolver from './binary-resolver'; describe('resolveClaudeBinaryPath (dev mode)', () => { - test('returns undefined when BUNDLED_IS_BINARY is false', async () => { - const result = await resolveClaudeBinaryPath(); + const originalEnv = process.env.CLAUDE_BIN_PATH; + let fileExistsSpy: ReturnType | undefined; + + beforeEach(() => { + delete process.env.CLAUDE_BIN_PATH; + fileExistsSpy?.mockRestore(); + fileExistsSpy = undefined; + }); + + afterAll(() => { + if (originalEnv !== undefined) { + process.env.CLAUDE_BIN_PATH = originalEnv; + } else { + delete process.env.CLAUDE_BIN_PATH; + } + fileExistsSpy?.mockRestore(); + }); + + test('returns undefined when nothing is configured', async () => { + const result = await resolver.resolveClaudeBinaryPath(); expect(result).toBeUndefined(); }); - test('returns undefined even with config path set', async () => { - const result = await resolveClaudeBinaryPath('/some/custom/path'); + test('returns undefined when only config path is set (config is binary-mode only)', async () => { + const result = await resolver.resolveClaudeBinaryPath('/some/custom/path'); expect(result).toBeUndefined(); }); - test('returns undefined even with env var set', async () => { - const original = process.env.CLAUDE_BIN_PATH; - process.env.CLAUDE_BIN_PATH = '/some/env/path'; - try { - const result = await resolveClaudeBinaryPath(); - expect(result).toBeUndefined(); - } finally { - if (original !== undefined) { - process.env.CLAUDE_BIN_PATH = original; - } else { - delete process.env.CLAUDE_BIN_PATH; - } - } + test('honors CLAUDE_BIN_PATH env var when file exists', async () => { + process.env.CLAUDE_BIN_PATH = '/usr/local/bin/claude'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveClaudeBinaryPath(); + expect(result).toBe('/usr/local/bin/claude'); + }); + + test('throws when CLAUDE_BIN_PATH is set but file does not exist', async () => { + process.env.CLAUDE_BIN_PATH = '/nonexistent/claude'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); + + await expect(resolver.resolveClaudeBinaryPath()).rejects.toThrow( + 'CLAUDE_BIN_PATH is set to "/nonexistent/claude" but the file does not exist' + ); + }); + + test('env var wins over config path in dev mode', async () => { + process.env.CLAUDE_BIN_PATH = '/env/claude'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveClaudeBinaryPath('/config/claude'); + expect(result).toBe('/env/claude'); + }); + + test('falls through to undefined when CLAUDE_BIN_PATH is the empty string', async () => { + // Pin the contract: an unset shell variable that gets exported as empty + // (e.g. `export CLAUDE_BIN_PATH=`) must behave the same as fully unset, + // not throw "file does not exist". + process.env.CLAUDE_BIN_PATH = ''; + const result = await resolver.resolveClaudeBinaryPath(); + expect(result).toBeUndefined(); }); }); diff --git a/packages/providers/src/claude/binary-resolver.ts b/packages/providers/src/claude/binary-resolver.ts index 6b918d44a5..5122e8790c 100644 --- a/packages/providers/src/claude/binary-resolver.ts +++ b/packages/providers/src/claude/binary-resolver.ts @@ -6,15 +6,17 @@ * own node_modules location; in compiled binaries that path is frozen to * the build host's filesystem and does not exist on end-user machines. * - * Resolution order (binary mode only): - * 1. `CLAUDE_BIN_PATH` environment variable - * 2. `assistants.claude.claudeBinaryPath` in config - * 3. Autodetect canonical install path (native installer default) - * 4. Throw with install instructions + * Resolution order: + * 1. `CLAUDE_BIN_PATH` environment variable (honored in both modes — escape + * hatch for hosts where the SDK's per-platform binary auto-resolution + * picks the wrong variant, e.g. glibc Linux + musl SDK package) + * 2. `assistants.claude.claudeBinaryPath` in config (binary mode only) + * 3. Autodetect canonical install path (binary mode only — native installer default) + * 4. Throw with install instructions (binary mode only) * - * In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the caller - * omits `pathToClaudeCodeExecutable` entirely and the SDK resolves via its - * normal node_modules lookup. + * In dev mode (BUNDLED_IS_BINARY=false), if no env var is set, returns + * undefined so the caller omits `pathToClaudeCodeExecutable` entirely and + * the SDK resolves via its normal node_modules lookup. */ import { existsSync as _existsSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -57,16 +59,18 @@ const INSTALL_INSTRUCTIONS = * legacy `cli.js` is still accepted for operators pinned to npm-installed * SDKs that ship a JS entry point). * - * In dev mode: returns undefined (let SDK resolve from its bundled per-platform - * native binary in `@anthropic-ai/claude-agent-sdk-`). - * In binary mode: resolves from env/config, or throws with install instructions. + * In dev mode: honors `CLAUDE_BIN_PATH` if set; otherwise returns undefined + * (let SDK resolve from its bundled per-platform native binary in + * `@anthropic-ai/claude-agent-sdk-`). + * In binary mode: resolves from env/config/autodetect, or throws with + * install instructions. */ export async function resolveClaudeBinaryPath( configClaudeBinaryPath?: string ): Promise { - if (!BUNDLED_IS_BINARY) return undefined; - - // 1. Environment variable override + // 1. Environment variable override — honored in dev mode too, so operators + // on libc mismatches (e.g. glibc host with the SDK's musl variant first in + // its resolution order) can pin a known-good binary without a compiled build. const envPath = process.env.CLAUDE_BIN_PATH; if (envPath) { if (!fileExists(envPath)) { @@ -80,6 +84,8 @@ export async function resolveClaudeBinaryPath( return envPath; } + if (!BUNDLED_IS_BINARY) return undefined; + // 2. Config file override if (configClaudeBinaryPath) { if (!fileExists(configClaudeBinaryPath)) {