fix(providers): replace Claude SDK embed with explicit binary-path resolver (#1217)

* feat(providers): replace Claude SDK embed with explicit binary-path resolver

Drop `@anthropic-ai/claude-agent-sdk/embed` and resolve Claude Code via
CLAUDE_BIN_PATH env → assistants.claude.claudeBinaryPath config → throw
with install instructions. The embed's silent failure modes on macOS
(#1210) and Windows (#1087) become actionable errors with a documented
recovery path.

Dev mode (bun run) remains auto-resolved via node_modules. The setup
wizard auto-detects Claude Code by probing the native installer path
(~/.local/bin/claude), npm global cli.js, and PATH, then writes
CLAUDE_BIN_PATH to ~/.archon/.env. Dockerfile pre-sets CLAUDE_BIN_PATH
so extenders using the compiled binary keep working. Release workflow
gets negative and positive resolver smoke tests.

Docs, CHANGELOG, README, .env.example, CLAUDE.md, test-release and
archon skills all updated to reflect the curl-first install story.

Retires #1210, #1087, #1091 (never merged, now obsolete).
Implements #1176.

* fix(providers): only pass --no-env-file when spawning Claude via Bun/Node

`--no-env-file` is a Bun flag that prevents Bun from auto-loading
`.env` from the subprocess cwd. It is only meaningful when the Claude
Code executable is a `cli.js` file — in which case the SDK spawns it
via `bun`/`node` and the flag reaches the runtime.

When `CLAUDE_BIN_PATH` points at a native compiled Claude binary (e.g.
`~/.local/bin/claude` from the curl installer, which is Anthropic's
recommended default), the SDK executes the binary directly. Passing
`--no-env-file` then goes straight to the native binary, which
rejects it with `error: unknown option '--no-env-file'` and the
subprocess exits code 1.

Emit `executableArgs` only when the target is a `.js` file (dev mode
or explicit cli.js path). Caught by end-to-end smoke testing against
the curl-installed native Claude binary.

* docs: record env-leak validation result in provider comment

Verified end-to-end with sentinel `.env` and `.env.local` files in a
workflow CWD that the native Claude binary (curl installer) does not
auto-load `.env` files. With Archon's full spawn pathway and parent
env stripped, the subprocess saw both sentinels as UNSET. The
first-layer protection in `@archon/paths` (#1067) handles the
inheritance leak; `--no-env-file` only matters for the Bun-spawned
cli.js path, where it is still emitted.

* chore(providers): cleanup pass — exports, docs, troubleshooting

Final-sweep cleanup tied to the binary-resolver PR:

- Mirror Codex's package surface for the new Claude resolver: add
  `./claude/binary-resolver` subpath export and re-export
  `resolveClaudeBinaryPath` + `claudeFileExists` from the package
  index. Renames the previously single `fileExists` re-export to
  `codexFileExists` for symmetry; nothing outside the providers
  package was importing it.
- Add a "Claude Code not found" entry to the troubleshooting reference
  doc with platform-specific install snippets and pointers to the
  AI Assistants binary-path section.
- Reframe the example claudeBinaryPath in reference/configuration.md
  away from cli.js-only language; it accepts either the native binary
  or cli.js.

* test+refactor(providers, cli): address PR review feedback

Two test gaps and one doc nit from the PR review (#1217):

- Extract the `--no-env-file` decision into a pure exported helper
  `shouldPassNoEnvFile(cliPath)` so the native-binary branch is unit
  testable without mocking `BUNDLED_IS_BINARY` or running the full
  sendQuery pathway. Six new tests cover undefined, cli.js, native
  binary (Linux + Windows), Homebrew symlink, and suffix-only matching.
  Also adds a `claude.subprocess_env_file_flag` debug log so the
  security-adjacent decision is auditable.

- Extract the three install-location probes in setup.ts into exported
  wrappers (`probeFileExists`, `probeNpmRoot`, `probeWhichClaude`) and
  export `detectClaudeExecutablePath` itself, so the probe order can be
  spied on. Six new tests cover each tier winning, fall-through
  ordering, npm-tier skip when not installed, and the
  which-resolved-but-stale-path edge case.

- CLAUDE.md `claudeBinaryPath` placeholder updated to reflect that the
  field accepts either the native binary or cli.js (the example value
  was previously `/absolute/path/to/cli.js`, slightly misleading now
  that the curl-installer native binary is the default).

Skipped from the review by deliberate scope decision:

- `resolveClaudeBinaryPath` async-with-no-await: matches Codex's
  resolver signature exactly. Changing only Claude breaks symmetry;
  if pursued, do both providers in a separate cleanup PR.
- `isAbsolute()` validation in parseClaudeConfig: Codex doesn't do it
  either. Resolver throws on non-existence already.
- Atomic `.env` writes in setup wizard: pre-existing pattern this PR
  touched only adjacently. File as separate issue if needed.
- classifyError branch in dag-executor for setup errors: scope creep.
- `.env.example` "missing #" claim: false positive (verified all
  CLAUDE_BIN_PATH lines have proper comment prefixes).

* fix(test): use path.join in Windows-compatible probe-order test

The "tier 2 wins (npm cli.js)" test hardcoded forward-slash path
comparisons, but `path.join` produces backslashes on Windows. Caused
the Windows CI leg of the test suite to fail while macOS and Linux
passed. Use `path.join` for both the mock return value and the
expectation so the separator matches whatever the platform produces.
This commit is contained in:
Rasmus Widing 2026-04-14 17:56:37 +03:00 committed by GitHub
parent 33d31c44f1
commit 81859d6842
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 946 additions and 19 deletions

View file

@ -119,6 +119,8 @@ If Bun was just installed in Prerequisites (macOS/Linux), use `~/.bun/bin/bun` i
3. Verify: `archon version`
4. Check Claude is installed: `which claude`, then `claude /login` if needed
> **Note — Claude Code binary path.** Archon does not bundle Claude Code. In compiled Archon binaries (quick install, Homebrew), the Claude Code SDK needs `CLAUDE_BIN_PATH` set to the absolute path of its `cli.js`. The `archon setup` wizard in Step 4 auto-detects this via `npm root -g` and writes it to `~/.archon/.env` — no manual action needed in the typical case. Source installs (`bun run`) don't need this; the SDK finds `cli.js` via `node_modules` automatically.
## Step 4: Configure Credentials
The CLI loads infrastructure config (database, tokens) from `~/.archon/.env` only. This prevents conflicts with project `.env` files that may contain different database URLs.

View file

@ -222,7 +222,23 @@ git commit -q --allow-empty -m init
### Test 3 — SDK path works (assist workflow)
In the same `$TESTREPO`:
**Prerequisite.** Compiled binaries require Claude Code installed on the host and a configured binary path. Before running this test, ensure one of:
```bash
# Option A — env var (easy for ad-hoc testing)
# After the native installer (Anthropic's default):
export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
# Or after npm global install:
export CLAUDE_BIN_PATH="$(npm root -g)/@anthropic-ai/claude-code/cli.js"
# Option B — config file (persistent)
# Add to ~/.archon/config.yaml:
# assistants:
# claude:
# claudeBinaryPath: /absolute/path/to/claude
```
Then in the same `$TESTREPO`:
```bash
"$BINARY" workflow run assist "say hello and nothing else" 2>&1 | tee /tmp/archon-test-assist.log
@ -232,15 +248,34 @@ In the same `$TESTREPO`:
- Exit code 0
- The Claude subprocess spawns successfully (no `spawn EACCES`, `ENOENT`, or `process exited with code 1` in the early output)
- No `Claude Code CLI not found` error (that means the resolver rejected the configured path — verify the cli.js actually exists)
- A response is produced (any response — even just "hello" — proves the SDK round-trip works)
**Common failures:**
- `Claude Code not found``CLAUDE_BIN_PATH` / `claudeBinaryPath` is unset or points at a non-existent file. Fix the path and re-run.
- `Module not found "/Users/runner/..."` → regression of #1210: the resolver was bypassed and the SDK's `import.meta.url` fallback leaked a build-host path. Investigate `packages/providers/src/claude/provider.ts` and the resolver.
- `Credit balance is too low` → auth is pointing at an exhausted API key (check `CLAUDE_USE_GLOBAL_AUTH` and `~/.archon/.env`)
- `unable to determine transport target for "pino-pretty"`#960 regression, binary crashes on TTY
- `package.json not found (bad installation?)`#961 regression, `isBinaryBuild` detection broken
- Process exits before producing output → generic spawn failure, capture stderr
### Test 3b — Resolver error path (run without `CLAUDE_BIN_PATH`)
Quickly verify the resolver fails loud when nothing is configured:
```bash
(unset CLAUDE_BIN_PATH; "$BINARY" workflow run assist "hello" 2>&1 | tee /tmp/archon-test-no-path.log)
```
**Pass criteria (when no `~/.archon/config.yaml` configures `claudeBinaryPath`):**
- Error message contains `Claude Code not found`
- Error message mentions both `CLAUDE_BIN_PATH` and `claudeBinaryPath` as remediation options
- No `Module not found` stack traces referencing the CI filesystem
If you *do* have `claudeBinaryPath` set globally, skip this test or temporarily rename `~/.archon/config.yaml`.
### Test 4 — Env-leak gate refuses a leaky .env (optional, for releases including #1036/#1038/#983)
Create a second throwaway repo with a fake sensitive key:

View file

@ -14,6 +14,20 @@ CLAUDE_USE_GLOBAL_AUTH=true
# CLAUDE_CODE_OAUTH_TOKEN=...
# CLAUDE_API_KEY=...
# Claude Code executable path (REQUIRED for compiled Archon binaries)
# Archon does not bundle Claude Code — install it separately and point us at it.
# Dev mode (`bun run`) auto-resolves via node_modules.
# Alternatively, set `assistants.claude.claudeBinaryPath` in ~/.archon/config.yaml.
#
# Install (Anthropic's recommended native installer):
# macOS/Linux: curl -fsSL https://claude.ai/install.sh | bash
# Windows: irm https://claude.ai/install.ps1 | iex
#
# Then:
# CLAUDE_BIN_PATH=$HOME/.local/bin/claude (native installer)
# CLAUDE_BIN_PATH=$(npm root -g)/@anthropic-ai/claude-code/cli.js (npm alternative)
# CLAUDE_BIN_PATH=
# Codex Authentication (get from ~/.codex/auth.json after running 'codex login')
# Required if using Codex as AI assistant
# On Linux/Mac: cat ~/.codex/auth.json

View file

@ -124,6 +124,83 @@ jobs:
exit 1
fi
- name: Smoke-test Claude binary-path resolver (negative case)
if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux'
run: |
# With no CLAUDE_BIN_PATH and no config, running a Claude workflow must
# fail with a clear, user-facing error — NOT with "Module not found
# /Users/runner/..." which would indicate the resolver was bypassed.
BIN="$PWD/dist/${{ matrix.binary }}"
TMP_REPO=$(mktemp -d)
cd "$TMP_REPO"
git init -q
git -c user.email=ci@example.com -c user.name=ci commit --allow-empty -q -m init
# Run without CLAUDE_BIN_PATH set. Expect a clean resolver error.
# Capture both stdout and stderr; we only care that the resolver message is present.
set +e
OUTPUT=$(env -u CLAUDE_BIN_PATH "$BIN" workflow run archon-assist "hello" 2>&1)
EXIT_CODE=$?
set -e
echo "$OUTPUT"
if echo "$OUTPUT" | grep -qE 'Module not found.*Users/runner'; then
echo "::error::Resolver was bypassed — SDK hit the import.meta.url fallback (regression of #1210)"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "Claude Code not found"; then
echo "::error::Expected 'Claude Code not found' error when CLAUDE_BIN_PATH is unset"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "CLAUDE_BIN_PATH"; then
echo "::error::Error message does not reference CLAUDE_BIN_PATH remediation"
exit 1
fi
echo "::notice::Resolver error path works (exit code: $EXIT_CODE)"
- name: Smoke-test Claude subprocess spawn (positive case)
if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux'
run: |
# Install Claude Code via the native installer (Anthropic's recommended
# default) and run a workflow with CLAUDE_BIN_PATH set. The subprocess
# must spawn cleanly. We do NOT require the query to succeed (no auth
# in CI — an auth error is fine and expected); we only fail if the SDK
# can't find the executable, which would indicate a resolver regression.
curl -fsSL https://claude.ai/install.sh | bash
CLI_PATH="$HOME/.local/bin/claude"
if [ ! -x "$CLI_PATH" ]; then
echo "::error::Claude Code binary not found after curl install at $CLI_PATH"
ls -la "$HOME/.local/bin/" || true
exit 1
fi
echo "Using CLAUDE_BIN_PATH=$CLI_PATH"
BIN="$PWD/dist/${{ matrix.binary }}"
TMP_REPO=$(mktemp -d)
cd "$TMP_REPO"
git init -q
git -c user.email=ci@example.com -c user.name=ci commit --allow-empty -q -m init
set +e
OUTPUT=$(CLAUDE_BIN_PATH="$CLI_PATH" "$BIN" workflow run archon-assist "hello" 2>&1)
EXIT_CODE=$?
set -e
echo "$OUTPUT"
if echo "$OUTPUT" | grep -qE 'Module not found.*(cli\.js|Users/runner)'; then
echo "::error::Subprocess could not find the executable (resolver regression)"
exit 1
fi
if echo "$OUTPUT" | grep -q "Claude Code not found"; then
echo "::error::Resolver failed even though CLAUDE_BIN_PATH was set to an existing file"
exit 1
fi
# Any of these outcomes are acceptable — they prove the subprocess spawned:
# - auth error ("credit balance", "unauthorized", "authentication")
# - rate-limit / API error
# - successful query (if auth was injected via some other mechanism)
echo "::notice::Claude subprocess spawn path is healthy (exit code: $EXIT_CODE)"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:

View file

@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Claude Code binary resolution** (breaking for compiled binary users): Archon no longer embeds the Claude Code SDK into compiled binaries. In compiled builds, you must install Claude Code separately (`curl -fsSL https://claude.ai/install.sh | bash` on macOS/Linux, `irm https://claude.ai/install.ps1 | iex` on Windows, or `npm install -g @anthropic-ai/claude-code`) and point Archon at the executable via `CLAUDE_BIN_PATH` env var or `assistants.claude.claudeBinaryPath` in `.archon/config.yaml`. The Claude Agent SDK accepts either the native compiled binary (from the curl/PowerShell installer at `~/.local/bin/claude`) or a JS `cli.js` (from the npm install). Dev mode (`bun run`) is unaffected — the SDK resolves via `node_modules` as before. The Docker image ships Claude Code pre-installed with `CLAUDE_BIN_PATH` pre-set, so `docker run` still works out of the box. Resolves silent "Module not found /Users/runner/..." failures on macOS (#1210) and Windows (#1087).
### Added
- **`CLAUDE_BIN_PATH` environment variable** — highest-precedence override for the Claude Code SDK `cli.js` path (#1176)
- **`assistants.claude.claudeBinaryPath` config option** — durable config-file alternative to the env var (#1176)
- **Release-workflow Claude subprocess smoke test** — the release CI now installs Claude Code on the Linux runner and exercises the resolver + subprocess spawn, catching binary-resolution regressions before they ship
### Removed
- **`@anthropic-ai/claude-agent-sdk/embed` import** — the Bun `with { type: 'file' }` asset-embedding path and its `$bunfs` extraction logic. The embed was a bundler-dependent optimization that failed silently when Bun couldn't produce a usable virtual FS path (#1210, #1087); it is replaced by explicit binary-path resolution.
### Fixed
- **Cross-clone worktree isolation**: prevent workflows in one local clone from silently adopting worktrees or DB state owned by another local clone of the same remote. Two clones sharing a remote previously resolved to the same `codebase_id`, causing the isolation resolver's DB-driven paths (`findReusable`, `findLinkedIssueEnv`, `tryBranchAdoption`) to return the other clone's environment. All adoption paths now verify the worktree's `.git` pointer matches the requesting clone and throw a classified error on mismatch. `archon-implement` prompt was also tightened to stop AI agents from adopting unrelated branches they see via `git branch`. Thanks to @halindrome for the three-issue root-cause mapping. (#1193, #1188, #1183, #1198, #1206)

View file

@ -468,6 +468,11 @@ assistants:
settingSources: # Controls which CLAUDE.md files Claude SDK loads
- project # Default: only project-level CLAUDE.md
- user # Optional: also load ~/.claude/CLAUDE.md
claudeBinaryPath: /absolute/path/to/claude # Optional: Claude Code executable.
# Native binary (curl installer at
# ~/.local/bin/claude) or npm cli.js.
# Required in compiled binaries if
# CLAUDE_BIN_PATH env var is not set.
codex:
model: gpt-5.3-codex
modelReasoningEffort: medium # 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'

View file

@ -108,6 +108,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
# Point agent-browser to system Chromium (avoids ~400MB Chrome for Testing download)
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
# Pre-configure the Claude Code SDK cli.js path for any consumer that runs
# a compiled Archon binary inside (or extending) this image. In source mode
# (the default `bun run start` ENTRYPOINT), BUNDLED_IS_BINARY is false and
# this variable is ignored — the SDK resolves cli.js via node_modules. Kept
# here so extenders don't need to rediscover the path.
# Path matches the hoisted layout produced by `bun install --linker=hoisted`.
ENV CLAUDE_BIN_PATH=/app/node_modules/@anthropic-ai/claude-agent-sdk/cli.js
# Create non-root user for running Claude Code
# Claude Code refuses to run with --dangerously-skip-permissions as root for security
RUN useradd -m -u 1001 -s /bin/bash appuser \

View file

@ -171,6 +171,22 @@ irm https://archon.diy/install.ps1 | iex
brew install coleam00/archon/archon
```
> **Compiled binaries need a `CLAUDE_BIN_PATH`.** The quick-install binaries
> don't bundle Claude Code. Install it separately, then point Archon at it:
>
> ```bash
> # macOS / Linux / WSL
> curl -fsSL https://claude.ai/install.sh | bash
> export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
>
> # Windows (PowerShell)
> irm https://claude.ai/install.ps1 | iex
> $env:CLAUDE_BIN_PATH = "$env:USERPROFILE\.local\bin\claude.exe"
> ```
>
> Or set `assistants.claude.claudeBinaryPath` in `~/.archon/config.yaml`.
> The Docker image ships Claude Code pre-installed. See [AI Assistants → Binary path configuration](https://archon.diy/docs/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only) for details.
### Start Using Archon
Once you've completed either setup path, go to your project and start working:

View file

@ -11,7 +11,9 @@ import {
generateWebhookSecret,
spawnTerminalWithSetup,
copyArchonSkill,
detectClaudeExecutablePath,
} from './setup';
import * as setupModule from './setup';
// Test directory for file operations
const TEST_DIR = join(tmpdir(), 'archon-setup-test-' + Date.now());
@ -176,6 +178,41 @@ CODEX_ACCOUNT_ID=account1
expect(content).toContain('CLAUDE_API_KEY=sk-test-key');
});
it('emits CLAUDE_BIN_PATH when claudeBinaryPath is configured', () => {
const content = generateEnvContent({
database: { type: 'sqlite' },
ai: {
claude: true,
claudeAuthType: 'global',
claudeBinaryPath: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js',
codex: false,
defaultAssistant: 'claude',
},
platforms: { github: false, telegram: false, slack: false, discord: false },
botDisplayName: 'Archon',
});
expect(content).toContain(
'CLAUDE_BIN_PATH=/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'
);
});
it('omits CLAUDE_BIN_PATH when not configured', () => {
const content = generateEnvContent({
database: { type: 'sqlite' },
ai: {
claude: true,
claudeAuthType: 'global',
codex: false,
defaultAssistant: 'claude',
},
platforms: { github: false, telegram: false, slack: false, discord: false },
botDisplayName: 'Archon',
});
expect(content).not.toContain('CLAUDE_BIN_PATH=');
});
it('should include platform configurations', () => {
const content = generateEnvContent({
database: { type: 'sqlite' },
@ -418,3 +455,82 @@ CODEX_ACCOUNT_ID=account1
});
});
});
describe('detectClaudeExecutablePath probe order', () => {
// Use spies on the exported probe wrappers so each tier can be controlled
// independently without touching the real filesystem or shell.
let fileExistsSpy: ReturnType<typeof spyOn>;
let npmRootSpy: ReturnType<typeof spyOn>;
let whichSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
fileExistsSpy = spyOn(setupModule, 'probeFileExists').mockReturnValue(false);
npmRootSpy = spyOn(setupModule, 'probeNpmRoot').mockReturnValue(null);
whichSpy = spyOn(setupModule, 'probeWhichClaude').mockReturnValue(null);
});
afterEach(() => {
fileExistsSpy.mockRestore();
npmRootSpy.mockRestore();
whichSpy.mockRestore();
});
it('returns the native installer path when present (tier 1 wins)', () => {
// Native path exists; subsequent probes must not be called.
fileExistsSpy.mockImplementation(
(p: string) => p.includes('.local/bin/claude') || p.includes('.local\\bin\\claude')
);
const result = detectClaudeExecutablePath();
expect(result).toBeTruthy();
expect(result).toMatch(/\.local[\\/]bin[\\/]claude/);
// Tier 2 / 3 must not have been consulted.
expect(npmRootSpy).not.toHaveBeenCalled();
expect(whichSpy).not.toHaveBeenCalled();
});
it('falls through to npm cli.js when native is missing (tier 2 wins)', () => {
// Use path.join so the expected result matches whatever separator the
// production code produces on the current platform (backslash on Windows,
// forward slash elsewhere).
const npmRoot = join('fake', 'npm', 'root');
const expectedCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js');
npmRootSpy.mockReturnValue(npmRoot);
fileExistsSpy.mockImplementation((p: string) => p === expectedCliJs);
const result = detectClaudeExecutablePath();
expect(result).toBe(expectedCliJs);
// Tier 3 must not have been consulted.
expect(whichSpy).not.toHaveBeenCalled();
});
it('falls through to which/where when native and npm probes both miss (tier 3 wins)', () => {
npmRootSpy.mockReturnValue('/fake/npm/root');
// Native miss, npm cli.js miss, but `which claude` returns a path that exists.
whichSpy.mockReturnValue('/opt/homebrew/bin/claude');
fileExistsSpy.mockImplementation((p: string) => p === '/opt/homebrew/bin/claude');
const result = detectClaudeExecutablePath();
expect(result).toBe('/opt/homebrew/bin/claude');
});
it('returns null when every probe misses', () => {
// All defaults already return false/null; nothing to override.
expect(detectClaudeExecutablePath()).toBeNull();
});
it('does not return a which-resolved path that fails the existsSync check', () => {
// `which` returns a path string but the file is not actually present
// (stale PATH entry, dangling symlink, etc.) — must not be returned.
npmRootSpy.mockReturnValue('/fake/npm/root');
whichSpy.mockReturnValue('/stale/path/claude');
fileExistsSpy.mockReturnValue(false);
expect(detectClaudeExecutablePath()).toBeNull();
});
it('skips npm tier when probeNpmRoot returns null (e.g. npm not installed)', () => {
// npm probe fails; tier 3 must still run.
whichSpy.mockReturnValue('/usr/local/bin/claude');
fileExistsSpy.mockImplementation((p: string) => p === '/usr/local/bin/claude');
const result = detectClaudeExecutablePath();
expect(result).toBe('/usr/local/bin/claude');
expect(npmRootSpy).toHaveBeenCalled();
});
});

View file

@ -44,6 +44,9 @@ interface SetupConfig {
claudeAuthType?: 'global' | 'apiKey' | 'oauthToken';
claudeApiKey?: string;
claudeOauthToken?: string;
/** Absolute path to Claude Code SDK's cli.js. Written as CLAUDE_BIN_PATH
* in ~/.archon/.env. Required in compiled Archon binaries; harmless in dev. */
claudeBinaryPath?: string;
codex: boolean;
codexTokens?: CodexTokens;
defaultAssistant: string;
@ -160,6 +163,85 @@ function isCommandAvailable(command: string): boolean {
}
}
/**
* Probe wrappers exported so tests can spy on each tier independently.
* Direct imports of `existsSync` and `execSync` cannot be intercepted by
* `spyOn` (esm rebinding limitation), so we route the probes through these
* thin wrappers and let the test mock them in isolation.
*/
export function probeFileExists(path: string): boolean {
return existsSync(path);
}
export function probeNpmRoot(): string | null {
try {
const out = execSync('npm root -g', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return out || null;
} catch {
return null;
}
}
export function probeWhichClaude(): string | null {
try {
const checkCmd = process.platform === 'win32' ? 'where' : 'which';
const resolved = execSync(`${checkCmd} claude`, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
// On Windows, `where` can return multiple lines — take the first.
const first = resolved.split(/\r?\n/)[0]?.trim();
return first ?? null;
} catch {
return null;
}
}
/**
* Try to locate the Claude Code executable on disk.
*
* Compiled Archon binaries need an explicit path because the Claude Agent
* SDK's `import.meta.url` resolution is frozen to the build host's filesystem.
* The SDK's `pathToClaudeCodeExecutable` accepts either:
* - A native compiled binary (from the curl/PowerShell/winget installers current default)
* - A JS `cli.js` (from `npm install -g @anthropic-ai/claude-code` older path)
*
* We probe the well-known install locations in order:
* 1. Native installer (`~/.local/bin/claude` on macOS/Linux, `%USERPROFILE%\.local\bin\claude.exe` on Windows)
* 2. npm global `cli.js`
* 3. `which claude` / `where claude` fallback if the user installed via Homebrew, winget, or a custom layout
*
* Returns null on total failure so the caller can prompt the user.
* Detection is best-effort; the caller should let users override.
*
* Exported so the probe order can be tested directly by spying on the
* tier wrappers above (`probeFileExists`, `probeNpmRoot`, `probeWhichClaude`).
*/
export function detectClaudeExecutablePath(): string | null {
// 1. Native installer default location (primary Anthropic-recommended path)
const nativePath =
process.platform === 'win32'
? join(homedir(), '.local', 'bin', 'claude.exe')
: join(homedir(), '.local', 'bin', 'claude');
if (probeFileExists(nativePath)) return nativePath;
// 2. npm global cli.js
const npmRoot = probeNpmRoot();
if (npmRoot) {
const npmCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js');
if (probeFileExists(npmCliJs)) return npmCliJs;
}
// 3. Fallback: resolve via `which` / `where` (Homebrew, winget, custom layouts)
const fromPath = probeWhichClaude();
if (fromPath && probeFileExists(fromPath)) return fromPath;
return null;
}
/**
* Get Node.js version if installed, or null if not
*/
@ -210,7 +292,7 @@ After installation, run: claude /login`,
Install using one of these methods:
Recommended for macOS (no Node.js required):
brew install --cask codex
brew install codex
Or via npm (requires Node.js 18+):
npm install -g @openai/codex
@ -353,6 +435,62 @@ function tryReadCodexAuth(): CodexTokens | null {
/**
* Collect Claude authentication method
*/
/**
* Resolve the Claude Code executable path for CLAUDE_BIN_PATH.
* Auto-detects common install locations and falls back to prompting the user.
* Returns undefined if the user declines to configure (setup continues; the
* compiled binary will error with clear instructions on first Claude query).
*/
async function collectClaudeBinaryPath(): Promise<string | undefined> {
const detected = detectClaudeExecutablePath();
if (detected) {
const useDetected = await confirm({
message: `Found Claude Code at ${detected}. Write this to CLAUDE_BIN_PATH?`,
initialValue: true,
});
if (isCancel(useDetected)) {
cancel('Setup cancelled.');
process.exit(0);
}
if (useDetected) return detected;
}
const nativeExample =
process.platform === 'win32' ? '%USERPROFILE%\\.local\\bin\\claude.exe' : '~/.local/bin/claude';
note(
'Compiled Archon binaries need CLAUDE_BIN_PATH set to the Claude Code executable.\n' +
'In dev (`bun run`) this is ignored — the SDK resolves it via node_modules.\n\n' +
'Recommended (Anthropic default — native installer):\n' +
` macOS/Linux: ${nativeExample}\n` +
' Windows: %USERPROFILE%\\.local\\bin\\claude.exe\n\n' +
'Alternative (npm global install):\n' +
' $(npm root -g)/@anthropic-ai/claude-code/cli.js',
'Claude binary path'
);
const customPath = await text({
message: 'Absolute path to the Claude Code executable (leave blank to skip):',
placeholder: nativeExample,
});
if (isCancel(customPath)) {
cancel('Setup cancelled.');
process.exit(0);
}
const trimmed = (customPath ?? '').trim();
if (!trimmed) return undefined;
if (!existsSync(trimmed)) {
log.warning(
`Path does not exist: ${trimmed}. Saving anyway — the compiled binary will error on first use until this is correct.`
);
}
return trimmed;
}
async function collectClaudeAuth(): Promise<{
authType: 'global' | 'apiKey' | 'oauthToken';
apiKey?: string;
@ -662,6 +800,7 @@ After upgrading, run 'archon setup' again.`,
let claudeAuthType: 'global' | 'apiKey' | 'oauthToken' | undefined;
let claudeApiKey: string | undefined;
let claudeOauthToken: string | undefined;
let claudeBinaryPath: string | undefined;
let codexTokens: CodexTokens | undefined;
// Collect Claude auth if selected
@ -670,6 +809,7 @@ After upgrading, run 'archon setup' again.`,
claudeAuthType = claudeAuth.authType;
claudeApiKey = claudeAuth.apiKey;
claudeOauthToken = claudeAuth.oauthToken;
claudeBinaryPath = await collectClaudeBinaryPath();
}
// Collect Codex auth if selected
@ -710,6 +850,7 @@ After upgrading, run 'archon setup' again.`,
claudeAuthType,
claudeApiKey,
claudeOauthToken,
...(claudeBinaryPath !== undefined ? { claudeBinaryPath } : {}),
codex: hasCodex,
codexTokens,
defaultAssistant,
@ -1070,6 +1211,9 @@ export function generateEnvContent(config: SetupConfig): string {
lines.push('CLAUDE_USE_GLOBAL_AUTH=false');
lines.push(`CLAUDE_CODE_OAUTH_TOKEN=${config.ai.claudeOauthToken}`);
}
if (config.ai.claudeBinaryPath) {
lines.push(`CLAUDE_BIN_PATH=${config.ai.claudeBinaryPath}`);
}
} else {
lines.push('# Claude not configured');
}

View file

@ -11,6 +11,11 @@ sidebar:
Deploy Archon on a server with Docker. Includes automatic HTTPS, PostgreSQL, and the Web UI.
> **Claude Code is pre-installed in the image.** The official `ghcr.io/coleam00/archon` image
> ships with Claude Code installed via npm and `CLAUDE_BIN_PATH` pre-set — no extra configuration
> required. If you build a custom image that omits the npm install, set `CLAUDE_BIN_PATH` yourself
> to point at a mounted `cli.js` (see [AI Assistants → Binary path configuration](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only)).
---
## Cloud-Init (Fastest Setup)

View file

@ -22,9 +22,11 @@ Local development with SQLite is the recommended default. No database setup is n
### Prerequisites
- [Bun](https://bun.sh) 1.0+
- At least one AI assistant configured (Claude Code or Codex)
- At least one AI assistant installed and configured (Claude Code or Codex — Archon orchestrates them, it does not bundle them)
- A GitHub token for repository cloning (`GH_TOKEN` / `GITHUB_TOKEN`)
> Source installs (`bun run`) auto-resolve Claude Code's `cli.js` via `node_modules`. Compiled Archon binaries require `CLAUDE_BIN_PATH` or `assistants.claude.claudeBinaryPath` — see [AI Assistants → Binary path configuration](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only).
### Setup
```bash

View file

@ -15,6 +15,64 @@ You must configure **at least one** AI assistant. Both can be configured if desi
**Recommended for Claude Pro/Max subscribers.**
Archon does not bundle Claude Code. Install it separately, then in compiled Archon binaries, point Archon at the executable. In dev (`bun run`), Archon finds it automatically via `node_modules`.
### Install Claude Code
Anthropic's native installer is the primary recommended install path:
**macOS / Linux / WSL:**
```bash
curl -fsSL https://claude.ai/install.sh | bash
```
**Windows (PowerShell):**
```powershell
irm https://claude.ai/install.ps1 | iex
```
**Alternatives:**
- macOS via Homebrew: `brew install --cask claude-code`
- npm (any platform): `npm install -g @anthropic-ai/claude-code`
- Windows via winget: `winget install Anthropic.ClaudeCode`
See [Anthropic's setup guide](https://code.claude.com/docs/en/setup) for the full list and auto-update caveats per install path.
### Binary path configuration (compiled binaries only)
Compiled Archon binaries cannot auto-discover Claude Code at runtime. Supply the path via either:
1. **Environment variable** (highest precedence):
```ini
CLAUDE_BIN_PATH=/absolute/path/to/claude
```
2. **Config file** (`~/.archon/config.yaml` or a repo-local `.archon/config.yaml`):
```yaml
assistants:
claude:
claudeBinaryPath: /absolute/path/to/claude
```
If neither is set in a compiled binary, Archon throws with install instructions on first Claude query.
The Claude Agent SDK accepts either the native compiled binary or a JS `cli.js`.
**Typical paths by install method:**
| Install method | Typical executable path |
|---|---|
| Native curl installer (macOS/Linux) | `~/.local/bin/claude` |
| Native PowerShell installer (Windows) | `%USERPROFILE%\.local\bin\claude.exe` |
| Homebrew cask | `$(brew --prefix)/bin/claude` (symlink) |
| npm global install | `$(npm root -g)/@anthropic-ai/claude-code/cli.js` |
| Windows winget | Resolvable via `where claude` |
| Docker (`ghcr.io/coleam00/archon`) | Pre-set via `ENV CLAUDE_BIN_PATH` in the image — no action required |
If in doubt, `which claude` (macOS/Linux) or `where claude` (Windows) will resolve the executable on your PATH after any of the installers above.
### Authentication Options
Claude Code supports three authentication modes via `CLAUDE_USE_GLOBAL_AUTH`:
@ -62,6 +120,9 @@ assistants:
settingSources:
- project # Default: only project-level CLAUDE.md
- user # Optional: also load ~/.claude/CLAUDE.md
# Optional: absolute path to the Claude Code executable.
# Required in compiled Archon binaries if CLAUDE_BIN_PATH is not set.
# claudeBinaryPath: /absolute/path/to/claude
```
The `settingSources` option controls which `CLAUDE.md` files the Claude Code SDK loads. By default, only the project-level `CLAUDE.md` is loaded. Add `user` to also load your personal `~/.claude/CLAUDE.md`.
@ -76,10 +137,46 @@ DEFAULT_AI_ASSISTANT=claude
## Codex
### Authenticate with Codex CLI
Archon does not bundle the Codex CLI. Install it, then authenticate.
### Install the Codex CLI
```bash
# Any platform (primary method):
npm install -g @openai/codex
# macOS alternative:
brew install codex
# Windows: npm install works but is experimental.
# OpenAI recommends WSL2 for the best experience.
```
Native prebuilt binaries (`.dmg`, `.tar.gz`, `.exe`) are also published on the [Codex releases page](https://github.com/openai/codex/releases) for users who prefer a direct binary — drop one in `~/.archon/vendor/codex/codex` (or `codex.exe` on Windows) and Archon will find it automatically in compiled binary mode.
See [OpenAI's Codex CLI docs](https://developers.openai.com/codex/cli) for the full install matrix.
### Binary path configuration (compiled binaries only)
In compiled Archon binaries, if `codex` is not on the default PATH Archon expects, supply the path via either:
1. **Environment variable** (highest precedence):
```ini
CODEX_BIN_PATH=/absolute/path/to/codex
```
2. **Config file** (`~/.archon/config.yaml`):
```yaml
assistants:
codex:
codexBinaryPath: /absolute/path/to/codex
```
3. **Vendor directory** (zero-config fallback): drop the native binary at `~/.archon/vendor/codex/codex` (or `codex.exe` on Windows).
Dev mode (`bun run`) does not require any of the above — the SDK resolves `codex` via `node_modules`.
### Authenticate
```bash
# Install Codex CLI first: https://docs.codex.com/installation
codex login
# Follow browser authentication flow

View file

@ -14,9 +14,11 @@ Set these in your shell or `.env` file:
| Variable | Required | Description |
|----------|----------|-------------|
| `CLAUDE_BIN_PATH` | Yes (binary builds) | Absolute path to the Claude Code SDK's `cli.js`. Required in compiled Archon binaries unless `assistants.claude.claudeBinaryPath` is set. Dev mode (`bun run`) auto-resolves via `node_modules`. |
| `CLAUDE_USE_GLOBAL_AUTH` | No | Set to `true` to use credentials from `claude /login` (default when no other Claude token is set) |
| `CLAUDE_CODE_OAUTH_TOKEN` | No | OAuth token from `claude setup-token` (alternative to global auth) |
| `CLAUDE_API_KEY` | No | Anthropic API key for pay-per-use (alternative to global auth) |
| `CODEX_BIN_PATH` | No | Absolute path to the Codex CLI binary. Overrides auto-detection in compiled Archon builds. |
| `CODEX_ACCESS_TOKEN` | Yes (for Codex) | Codex access token (see [AI Assistants](/getting-started/ai-assistants/)) |
| `DATABASE_URL` | No | PostgreSQL connection string (default: SQLite) |
| `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, `error` |

View file

@ -47,6 +47,42 @@ bun install
- [GitHub CLI](https://cli.github.com/) (`gh`)
- [Claude Code](https://claude.ai/code) (`claude`)
## Claude Code is required
Archon orchestrates Claude Code; it does not bundle it. Install Claude Code separately:
```bash
# macOS / Linux / WSL (Anthropic's recommended installer)
curl -fsSL https://claude.ai/install.sh | bash
# Windows (PowerShell)
irm https://claude.ai/install.ps1 | iex
```
Source installs (`bun run`) find the executable automatically via `node_modules`. Compiled binaries (quick install, Homebrew) must point at the Claude Code executable:
```bash
# After the native installer:
export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
# After `npm install -g @anthropic-ai/claude-code`:
export CLAUDE_BIN_PATH="$(npm root -g)/@anthropic-ai/claude-code/cli.js"
```
Or set it durably in `~/.archon/config.yaml`:
```yaml
assistants:
claude:
claudeBinaryPath: /absolute/path/to/claude
```
Docker images (`ghcr.io/coleam00/archon`) ship with Claude Code pre-installed and
`CLAUDE_BIN_PATH` pre-set — no configuration needed.
See [AI Assistants → Claude Code](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only)
for full details and install-layout paths.
## Verify Installation
```bash

View file

@ -20,7 +20,7 @@ Before you start, make sure you have:
| -------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------- |
| **Git** | `git --version` | [git-scm.com](https://git-scm.com/) |
| **Bun** (replaces Node.js + npm) | `bun --version` | Linux/macOS: `curl -fsSL https://bun.sh/install \| bash` — Windows: `powershell -c "irm bun.sh/install.ps1 \| iex"` |
| **Claude Code CLI** | `claude --version` | [docs.claude.com/claude-code/installation](https://docs.claude.com/en/docs/claude-code/installation) |
| **Claude Code CLI** | `claude --version` | [docs.claude.com/claude-code/installation](https://docs.claude.com/en/docs/claude-code/installation) — in compiled Archon binaries, also set `CLAUDE_BIN_PATH` ([details](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only)) |
| **GitHub account** | — | [github.com](https://github.com/) |
> **Do not run as root.** Archon (and the Claude Code CLI it depends on) does not work when run as the `root` user. If you're on a VPS or server that only has root, create a regular user first:

View file

@ -10,8 +10,10 @@ sidebar:
## Prerequisites
1. [Install Archon](/getting-started/installation/)
2. Authenticate with Claude: run `claude /login` (uses your existing Claude Pro/Max subscription)
3. Navigate to any git repository
2. [Install Claude Code](/getting-started/ai-assistants/#claude-code) — Archon orchestrates it but does not bundle it
3. Authenticate with Claude: run `claude /login` (uses your existing Claude Pro/Max subscription)
4. In compiled Archon binaries, set `CLAUDE_BIN_PATH` (see [Binary path configuration](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only))
5. Navigate to any git repository
## Run Your First Workflow

View file

@ -60,12 +60,18 @@ assistants:
settingSources: # Which CLAUDE.md files the SDK loads (default: ['project'])
- project # Project-level CLAUDE.md (always recommended)
- user # Also load ~/.claude/CLAUDE.md (global preferences)
# Optional: absolute path to the Claude Code executable.
# Required in compiled Archon binaries when CLAUDE_BIN_PATH is not set.
# Accepts the native binary (~/.local/bin/claude from the curl installer)
# or the npm-installed cli.js. Source/dev mode auto-resolves.
# claudeBinaryPath: /absolute/path/to/claude
codex:
model: gpt-5.3-codex
modelReasoningEffort: medium
webSearchMode: disabled
additionalDirectories:
- /absolute/path/to/other/repo
# codexBinaryPath: /absolute/path/to/codex # Optional: Codex CLI path
# Streaming preferences per platform
streaming:

View file

@ -280,6 +280,41 @@ docker compose exec app ls -la /.archon/workspaces
docker compose exec app git clone https://github.com/user/repo /.archon/workspaces/test-repo
```
## "Claude Code not found" When Running Compiled Binary
**Symptom:** A workflow that uses Claude fails with:
```
Claude Code not found. Archon requires the Claude Code executable to be
reachable at a configured path in compiled builds.
```
**Cause:** Compiled Archon binaries (`archon` from the curl/PowerShell installer or Homebrew) do not bundle Claude Code. They need an explicit path to the Claude Code executable. Source/dev mode (`bun run`) auto-resolves via `node_modules` and is unaffected.
**Fix:** Install Claude Code separately and point Archon at it.
```bash
# macOS / Linux / WSL — Anthropic's recommended native installer
curl -fsSL https://claude.ai/install.sh | bash
export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
# Windows (PowerShell)
irm https://claude.ai/install.ps1 | iex
$env:CLAUDE_BIN_PATH = "$env:USERPROFILE\.local\bin\claude.exe"
```
For a durable setup, set the path in `~/.archon/config.yaml` instead:
```yaml
assistants:
claude:
claudeBinaryPath: /absolute/path/to/claude
```
`archon setup` auto-detects and writes `CLAUDE_BIN_PATH` for you. Docker users do not need to do anything — the image pre-sets the variable.
See the [AI Assistants → Binary path configuration](/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only) guide for the full install matrix.
## Workflows Hang Silently When Run Inside Claude Code
**Symptom:** Workflows started from within a Claude Code session (e.g., via the Terminal tool) produce no output, or the CLI emits a warning about `CLAUDECODE=1` before the workflow hangs.

View file

@ -9,6 +9,7 @@
"./types": "./src/types.ts",
"./claude/provider": "./src/claude/provider.ts",
"./claude/config": "./src/claude/config.ts",
"./claude/binary-resolver": "./src/claude/binary-resolver.ts",
"./codex/provider": "./src/codex/provider.ts",
"./codex/config": "./src/codex/config.ts",
"./codex/binary-resolver": "./src/codex/binary-resolver.ts",
@ -16,7 +17,7 @@
"./registry": "./src/registry.ts"
},
"scripts": {
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts",
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {

View file

@ -0,0 +1,40 @@
/**
* Tests for the Claude binary resolver in dev mode (BUNDLED_IS_BINARY=false).
* Separate file because binary-mode tests mock BUNDLED_IS_BINARY=true.
*/
import { describe, test, expect, mock } 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';
describe('resolveClaudeBinaryPath (dev mode)', () => {
test('returns undefined when BUNDLED_IS_BINARY is false', async () => {
const result = await resolveClaudeBinaryPath();
expect(result).toBeUndefined();
});
test('returns undefined even with config path set', async () => {
const result = await 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;
}
}
});
});

View file

@ -0,0 +1,91 @@
/**
* Tests for the Claude binary resolver in binary mode.
*
* Must run in its own bun test invocation because it mocks @archon/paths
* with BUNDLED_IS_BINARY=true, which conflicts with other test files.
*/
import { describe, test, expect, mock, beforeEach, afterAll, spyOn } from 'bun:test';
import { createMockLogger } from '../test/mocks/logger';
const mockLogger = createMockLogger();
// Mock @archon/paths with BUNDLED_IS_BINARY = true (binary mode)
mock.module('@archon/paths', () => ({
createLogger: mock(() => mockLogger),
BUNDLED_IS_BINARY: true,
}));
import * as resolver from './binary-resolver';
describe('resolveClaudeBinaryPath (binary mode)', () => {
const originalEnv = process.env.CLAUDE_BIN_PATH;
let fileExistsSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
delete process.env.CLAUDE_BIN_PATH;
fileExistsSpy?.mockRestore();
mockLogger.info.mockClear();
});
afterAll(() => {
if (originalEnv !== undefined) {
process.env.CLAUDE_BIN_PATH = originalEnv;
} else {
delete process.env.CLAUDE_BIN_PATH;
}
fileExistsSpy?.mockRestore();
});
test('uses CLAUDE_BIN_PATH env var when set and file exists', async () => {
process.env.CLAUDE_BIN_PATH = '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js';
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);
const result = await resolver.resolveClaudeBinaryPath();
expect(result).toBe('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js');
});
test('throws when CLAUDE_BIN_PATH is set but file does not exist', async () => {
process.env.CLAUDE_BIN_PATH = '/nonexistent/cli.js';
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);
await expect(resolver.resolveClaudeBinaryPath()).rejects.toThrow(
'CLAUDE_BIN_PATH is set to "/nonexistent/cli.js" but the file does not exist'
);
});
test('uses config claudeBinaryPath when file exists', async () => {
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);
const result = await resolver.resolveClaudeBinaryPath('/custom/claude/cli.js');
expect(result).toBe('/custom/claude/cli.js');
});
test('throws when config claudeBinaryPath file does not exist', async () => {
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);
await expect(resolver.resolveClaudeBinaryPath('/nonexistent/cli.js')).rejects.toThrow(
'assistants.claude.claudeBinaryPath is set to "/nonexistent/cli.js" but the file does not exist'
);
});
test('env var takes precedence over config path', async () => {
process.env.CLAUDE_BIN_PATH = '/env/cli.js';
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);
const result = await resolver.resolveClaudeBinaryPath('/config/cli.js');
expect(result).toBe('/env/cli.js');
});
test('throws with install instructions when nothing configured', async () => {
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);
const promise = resolver.resolveClaudeBinaryPath();
await expect(promise).rejects.toThrow('Claude Code not found');
await expect(promise).rejects.toThrow('CLAUDE_BIN_PATH');
// Native curl installer is Anthropic's primary recommendation.
await expect(promise).rejects.toThrow('https://claude.ai/install.sh');
// npm path is still documented as an alternative.
await expect(promise).rejects.toThrow('npm install -g @anthropic-ai/claude-code');
await expect(promise).rejects.toThrow('claudeBinaryPath');
});
});

View file

@ -0,0 +1,94 @@
/**
* Claude Code CLI resolver for compiled (bun --compile) archon binaries.
*
* The @anthropic-ai/claude-agent-sdk spawns a subprocess using
* `pathToClaudeCodeExecutable`. In dev mode the SDK resolves this from its
* 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. Throw with install instructions
*
* 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.
*/
import { existsSync as _existsSync } from 'node:fs';
import { BUNDLED_IS_BINARY, createLogger } from '@archon/paths';
/** Wrapper for existsSync — enables spyOn in tests (direct imports can't be spied on). */
export function fileExists(path: string): boolean {
return _existsSync(path);
}
/** Lazy-initialized logger */
let cachedLog: ReturnType<typeof createLogger> | undefined;
function getLog(): ReturnType<typeof createLogger> {
if (!cachedLog) cachedLog = createLogger('claude-binary');
return cachedLog;
}
const INSTALL_INSTRUCTIONS =
'Claude Code not found. Archon requires the Claude Code executable to be\n' +
'reachable at a configured path in compiled builds.\n\n' +
'To fix, install Claude Code and point Archon at it:\n\n' +
' macOS / Linux (recommended — native installer):\n' +
' curl -fsSL https://claude.ai/install.sh | bash\n' +
' export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"\n\n' +
' Windows (PowerShell):\n' +
' irm https://claude.ai/install.ps1 | iex\n' +
' $env:CLAUDE_BIN_PATH = "$env:USERPROFILE\\.local\\bin\\claude.exe"\n\n' +
' Or via npm (alternative):\n' +
' npm install -g @anthropic-ai/claude-code\n' +
' export CLAUDE_BIN_PATH="$(npm root -g)/@anthropic-ai/claude-code/cli.js"\n\n' +
'Persist the path in ~/.archon/config.yaml instead of the env var:\n' +
' assistants:\n' +
' claude:\n' +
' claudeBinaryPath: /absolute/path/to/claude\n\n' +
'See: https://archon.diy/docs/reference/configuration#claude';
/**
* Resolve the path to the Claude Code SDK's cli.js.
*
* In dev mode: returns undefined (let SDK resolve via node_modules).
* In binary mode: resolves from env/config, or throws with install instructions.
*/
export async function resolveClaudeBinaryPath(
configClaudeBinaryPath?: string
): Promise<string | undefined> {
if (!BUNDLED_IS_BINARY) return undefined;
// 1. Environment variable override
const envPath = process.env.CLAUDE_BIN_PATH;
if (envPath) {
if (!fileExists(envPath)) {
throw new Error(
`CLAUDE_BIN_PATH is set to "${envPath}" but the file does not exist.\n` +
'Please verify the path points to the Claude Code executable (native binary\n' +
'from the curl/PowerShell installer, or cli.js from an npm global install).'
);
}
getLog().info({ binaryPath: envPath, source: 'env' }, 'claude.binary_resolved');
return envPath;
}
// 2. Config file override
if (configClaudeBinaryPath) {
if (!fileExists(configClaudeBinaryPath)) {
throw new Error(
`assistants.claude.claudeBinaryPath is set to "${configClaudeBinaryPath}" but the file does not exist.\n` +
'Please verify the path in .archon/config.yaml points to the Claude Code executable.'
);
}
getLog().info(
{ binaryPath: configClaudeBinaryPath, source: 'config' },
'claude.binary_resolved'
);
return configClaudeBinaryPath;
}
// 3. Not found — throw with install instructions
throw new Error(INSTALL_INSTRUCTIONS);
}

View file

@ -27,5 +27,9 @@ export function parseClaudeConfig(raw: Record<string, unknown>): ClaudeProviderD
}
}
if (typeof raw.claudeBinaryPath === 'string') {
result.claudeBinaryPath = raw.claudeBinaryPath;
}
return result;
}

View file

@ -16,9 +16,39 @@ mock.module('@anthropic-ai/claude-agent-sdk', () => ({
query: mockQuery,
}));
import { ClaudeProvider } from './provider';
import { ClaudeProvider, shouldPassNoEnvFile } from './provider';
import * as claudeModule from './provider';
describe('shouldPassNoEnvFile', () => {
test('returns true when cliPath is undefined (dev mode — SDK spawns cli.js via Bun)', () => {
expect(shouldPassNoEnvFile(undefined)).toBe(true);
});
test('returns true for an explicit cli.js path (npm-installed, SDK spawns via Bun/Node)', () => {
expect(
shouldPassNoEnvFile('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js')
).toBe(true);
});
test('returns false for a native binary path (curl installer, SDK execs directly)', () => {
expect(shouldPassNoEnvFile('/Users/test/.local/bin/claude')).toBe(false);
});
test('returns false for a Windows native binary path', () => {
expect(shouldPassNoEnvFile('C:\\Users\\test\\.local\\bin\\claude.exe')).toBe(false);
});
test('returns false for a Homebrew symlink path', () => {
expect(shouldPassNoEnvFile('/opt/homebrew/bin/claude')).toBe(false);
});
test('extension match is suffix-only (paths ending in cli.js but not literally `.js` extension are still rejected)', () => {
// Defensive: only string-suffix matches `.js` count as JS executables.
expect(shouldPassNoEnvFile('/path/to/cli.json')).toBe(false);
expect(shouldPassNoEnvFile('/path/to/cli.js.bak')).toBe(false);
});
});
describe('ClaudeProvider', () => {
let client: ClaudeProvider;

View file

@ -11,6 +11,12 @@
* - CLAUDE_USE_GLOBAL_AUTH=true: Use global auth from `claude /login`, filter env tokens
* - CLAUDE_USE_GLOBAL_AUTH=false: Use explicit tokens from env vars
* - Not set: Auto-detect - use tokens if present in env, otherwise global auth
*
* Binary resolution:
* - In compiled binaries, `pathToClaudeCodeExecutable` is resolved from
* `CLAUDE_BIN_PATH` env or `assistants.claude.claudeBinaryPath` config;
* see ./binary-resolver.ts. In dev mode the SDK resolves cli.js itself
* from node_modules.
*/
import {
query,
@ -18,7 +24,6 @@ import {
type HookCallback,
type HookCallbackMatcher,
} from '@anthropic-ai/claude-agent-sdk';
import cliPath from '@anthropic-ai/claude-agent-sdk/embed';
import type {
IAgentProvider,
SendQueryOptions,
@ -29,6 +34,7 @@ import type {
} from '../types';
import { parseClaudeConfig } from './config';
import { CLAUDE_CAPABILITIES } from './capabilities';
import { resolveClaudeBinaryPath } from './binary-resolver';
import { createLogger } from '@archon/paths';
import { readFile } from 'fs/promises';
import { resolve, isAbsolute } from 'path';
@ -499,6 +505,33 @@ interface ToolResultEntry {
toolCallId?: string;
}
/**
* Decide whether the Claude subprocess should be spawned with `--no-env-file`.
*
* `--no-env-file` is a Bun flag that prevents auto-loading `.env` from the
* target repo cwd into the spawned process. It only applies when the SDK
* spawns the executable via Bun/Node i.e. when the executable is a `.js`
* file (dev mode resolves cli.js, npm-installed resolves cli.js). For a
* native Claude Code binary (curl/PowerShell installer at
* `~/.local/bin/claude`), the SDK execs the binary directly and the flag
* gets passed to the native binary, which rejects unknown options and
* exits code 1.
*
* Returning `false` for native binaries is verified safe the native
* binary does not auto-load `.env` from CWD (probed end-to-end with
* sentinel `.env` and `.env.local` in the workflow CWD; both arrived
* UNSET in the spawned bash tool). The first-layer protection
* `stripCwdEnv()` in `@archon/paths` (#1067) removes CWD env keys from
* the parent process before spawn, so the subprocess inherits a clean
* env regardless of executable type.
*
* Exported so the decision can be unit-tested without needing to mock
* `BUNDLED_IS_BINARY` or run the full provider sendQuery pathway.
*/
export function shouldPassNoEnvFile(cliPath: string | undefined): boolean {
return cliPath === undefined || cliPath.endsWith('.js');
}
/**
* Build base Claude SDK options from cwd, request options, and assistant defaults.
* Does not include nodeConfig translation that is handled by applyNodeConfig.
@ -510,14 +543,21 @@ function buildBaseClaudeOptions(
controller: AbortController,
stderrLines: string[],
toolResultQueue: ToolResultEntry[],
env: NodeJS.ProcessEnv
env: NodeJS.ProcessEnv,
cliPath: string | undefined
): Options {
const isJsExecutable = shouldPassNoEnvFile(cliPath);
getLog().debug(
{ cliPath: cliPath ?? null, isJsExecutable, passesNoEnvFile: isJsExecutable },
'claude.subprocess_env_file_flag'
);
return {
cwd,
pathToClaudeCodeExecutable: cliPath,
// Prevent Bun from auto-loading .env from the target repo cwd.
// Without this, the Claude Code subprocess inherits repo secrets.
executableArgs: ['--no-env-file'],
// In compiled binaries, the resolver supplies an absolute executable path;
// in dev mode it returns undefined and the SDK resolves from node_modules.
...(cliPath !== undefined ? { pathToClaudeCodeExecutable: cliPath } : {}),
...(isJsExecutable ? { executableArgs: ['--no-env-file'] } : {}),
env,
model: requestOptions?.model ?? assistantDefaults.model,
abortController: controller,
@ -840,6 +880,11 @@ export class ClaudeProvider implements IAgentProvider {
let lastError: Error | undefined;
const assistantDefaults = parseClaudeConfig(requestOptions?.assistantConfig ?? {});
// Resolve Claude CLI path once before the retry loop. In binary mode this
// throws immediately if neither env nor config supplies a valid path, so
// the user gets a clean error rather than N retries of "Module not found".
const resolvedCliPath = await resolveClaudeBinaryPath(assistantDefaults.claudeBinaryPath);
// Build subprocess env once (avoids re-logging auth mode per retry)
const subprocessEnv = buildSubprocessEnv();
const env = requestOptions?.env ? { ...subprocessEnv, ...requestOptions.env } : subprocessEnv;
@ -879,7 +924,7 @@ export class ClaudeProvider implements IAgentProvider {
const controller = new AbortController();
currentController = controller;
// 1. Build SDK options (env pre-computed above)
// 1. Build SDK options (env and cliPath pre-computed above)
const options = buildBaseClaudeOptions(
cwd,
requestOptions,
@ -887,7 +932,8 @@ export class ClaudeProvider implements IAgentProvider {
controller,
stderrLines,
toolResultQueue,
env
env,
resolvedCliPath
);
// 2. Apply nodeConfig translation (re-applied per attempt since options are fresh)

View file

@ -42,4 +42,5 @@ export { parseCodexConfig, type CodexProviderDefaults } from './codex/config';
// Utilities (needed by consumers)
export { resetCodexSingleton } from './codex/provider';
export { resolveCodexBinaryPath, fileExists } from './codex/binary-resolver';
export { resolveCodexBinaryPath, fileExists as codexFileExists } from './codex/binary-resolver';
export { resolveClaudeBinaryPath, fileExists as claudeFileExists } from './claude/binary-resolver';

View file

@ -13,6 +13,10 @@ export interface ClaudeProviderDefaults {
* @default ['project']
*/
settingSources?: ('project' | 'user')[];
/** Absolute path to the Claude Code SDK's `cli.js`. Required in compiled
* Archon builds when `CLAUDE_BIN_PATH` is not set; optional in dev mode
* (SDK resolves from node_modules). */
claudeBinaryPath?: string;
}
export interface CodexProviderDefaults {