Skip to content

bug(claude): dev-mode workflows fail on glibc Linux due to SDK musl-first auto-resolution; honor CLAUDE_BIN_PATH as escape hatch #1474

@dtherrien

Description

@dtherrien

Summary

On glibc Linux hosts (Ubuntu/Debian/Fedora/etc.), archon workflow run fails immediately when running from source (dev mode). The Claude Code SDK auto-resolves its bundled binary in
[linux-x64-musl, linux-x64] order, so the musl variant is selected first; its ELF interpreter (/lib/ld-musl-x86_64.so.1) doesn't exist on glibc systems, and the spawn fails with a
misleading error that blames a missing path.

The error currently surfaces as:

ReferenceError: Claude Code native binary not found at
  .../node_modules/.bun/@anthropic-ai+claude-agent-sdk-linux-x64-musl@0.2.121
  /node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude

The file is on disk and executable; the spawn failure is from the missing musl loader, not a missing binary.

Reproduction

  1. Install Archon from source on a glibc host (any current Ubuntu/Debian/Fedora).
  2. bun run dev:server (or just bun /home/$USER/.bun/bin/archon workflow run ...).
  3. Run any workflow: archon workflow run archon-assist --branch test "anything".
  4. Workflow fails in <1s with the error above.

Both @anthropic-ai/claude-agent-sdk-linux-x64-musl and @anthropic-ai/claude-agent-sdk-linux-x64 install via optionalDependencies on Linux (libc field in their package.json doesn't
constrain Bun's install), and the SDK's resolver (function N7 in sdk.mjs) tries musl first.

Setting CLAUDE_BIN_PATH=/home/<user>/.local/bin/claude (the documented escape hatch) does not fix it: binary-resolver.ts early-returns undefined whenever
BUNDLED_IS_BINARY=false, before the env var is even read. There is no working override for dev mode short of patching node_modules.

Proposed fix

Move the CLAUDE_BIN_PATH env-var check above the BUNDLED_IS_BINARY early return in packages/providers/src/claude/binary-resolver.ts. Config-file path stays binary-mode-only (it's
per-repo, not per-machine — the env var is the right knob for libc-mismatch escape).

Behavior preserved:

  • env var unset → identical to today (returns undefined in dev, falls through to autodetect/throw in binary mode)
  • env var set + file exists → resolved binary used (was already true in binary mode; now also true in dev mode)
  • env var set + file missing → clear error message (was already true in binary mode; now also true in dev mode)

Patch

diff --git a/packages/providers/src/claude/binary-resolver.ts b/packages/providers/src/claude/binary-resolver.ts
index 6b918d44..0658374e 100644
--- a/packages/providers/src/claude/binary-resolver.ts
+++ b/packages/providers/src/claude/binary-resolver.ts
@@ -64,9 +64,9 @@ const 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 musl SDK variant first in the
+  // resolution list) can pin a known-good binary without a compiled build.
   const envPath = process.env.CLAUDE_BIN_PATH;
   if (envPath) {
     if (!fileExists(envPath)) {
@@ -80,6 +80,8 @@ export async function resolveClaudeBinaryPath(
     return envPath;
   }

+  if (!BUNDLED_IS_BINARY) return undefined;
+
   // 2. Config file override
   if (configClaudeBinaryPath) {
     if (!fileExists(configClaudeBinaryPath)) {

Tests

The existing binary-resolver-dev.test.ts asserts the old behavior (returns undefined even with env var set). It needs to be updated to reflect the new contract. Replacement:

/**
 * 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, beforeEach, afterAll, spyOn } from 'bun:test';
import { createMockLogger } from '../test/mocks/logger';

mock.module('@archon/paths', () => ({
  createLogger: mock(() => createMockLogger()),
  BUNDLED_IS_BINARY: false,
}));

import * as resolver from './binary-resolver';

describe('resolveClaudeBinaryPath (dev mode)', () => {
  const originalEnv = process.env.CLAUDE_BIN_PATH;
  let fileExistsSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    delete process.env.CLAUDE_BIN_PATH;
    fileExistsSpy?.mockRestore();
  });

  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 when only config path is set (config is binary-mode only)', async () => {
    const result = await resolver.resolveClaudeBinaryPath('/some/custom/path');
    expect(result).toBeUndefined();
  });

  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');
  });
});

Both test files pass locally:

  • bun test packages/providers/src/claude/binary-resolver-dev.test.ts → 5 pass
  • bun test packages/providers/src/claude/binary-resolver.test.ts → 9 pass (binary-mode tests, unaffected — env var was already step 1 there)

Environment

  • OS: Ubuntu (glibc 2.33)
  • Bun: 1.3.13
  • Archon: dev branch at bf1f471e
  • Claude Code SDK: @anthropic-ai/claude-agent-sdk@0.2.121
  • Reproduces with CLAUDECODE=1 set or unset (the nested-Claude warning is unrelated).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions