Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>` (or `bun -e <body>`) — 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
82 changes: 63 additions & 19 deletions packages/providers/src/claude/binary-resolver-dev.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,84 @@
/**
* 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', () => ({
createLogger: mock(() => createMockLogger()),
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<typeof spyOn> | 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();
});
});
34 changes: 20 additions & 14 deletions packages/providers/src/claude/binary-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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-<platform>`).
* 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-<platform>`).
* In binary mode: resolves from env/config/autodetect, or throws with
* install instructions.
*/
export async function resolveClaudeBinaryPath(
configClaudeBinaryPath?: string
): Promise<string | undefined> {
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)) {
Expand All @@ -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)) {
Expand Down
Loading