From 33a242e205a5cf1e01696d24eacd666ddde00ecc Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 16:12:28 +0300 Subject: [PATCH 1/6] feat(providers): replace Claude SDK embed with explicit binary-path resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/skills/archon/guides/setup.md | 2 + .claude/skills/test-release/SKILL.md | 37 +++++- .env.example | 14 ++ .github/workflows/release.yml | 77 +++++++++++ CHANGELOG.md | 14 ++ CLAUDE.md | 3 + Dockerfile | 8 ++ README.md | 16 +++ packages/cli/src/commands/setup.test.ts | 35 +++++ packages/cli/src/commands/setup.ts | 123 +++++++++++++++++- .../src/content/docs/deployment/docker.md | 5 + .../src/content/docs/deployment/local.md | 4 +- .../docs/getting-started/ai-assistants.md | 101 +++++++++++++- .../docs/getting-started/configuration.md | 2 + .../docs/getting-started/installation.md | 36 +++++ .../content/docs/getting-started/overview.md | 2 +- .../docs/getting-started/quick-start.md | 6 +- .../content/docs/reference/configuration.md | 4 + packages/providers/package.json | 2 +- .../src/claude/binary-resolver-dev.test.ts | 40 ++++++ .../src/claude/binary-resolver.test.ts | 91 +++++++++++++ .../providers/src/claude/binary-resolver.ts | 94 +++++++++++++ packages/providers/src/claude/config.ts | 4 + packages/providers/src/claude/provider.ts | 25 +++- packages/providers/src/types.ts | 4 + 25 files changed, 735 insertions(+), 14 deletions(-) create mode 100644 packages/providers/src/claude/binary-resolver-dev.test.ts create mode 100644 packages/providers/src/claude/binary-resolver.test.ts create mode 100644 packages/providers/src/claude/binary-resolver.ts diff --git a/.claude/skills/archon/guides/setup.md b/.claude/skills/archon/guides/setup.md index 30c651d70c..d964882452 100644 --- a/.claude/skills/archon/guides/setup.md +++ b/.claude/skills/archon/guides/setup.md @@ -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. diff --git a/.claude/skills/test-release/SKILL.md b/.claude/skills/test-release/SKILL.md index c8cfc3c4f3..31029014ea 100644 --- a/.claude/skills/test-release/SKILL.md +++ b/.claude/skills/test-release/SKILL.md @@ -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: diff --git a/.env.example b/.env.example index 3c42151aee..16caa43266 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aabb0e05d4..d50be15651 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e862caf2d..a9b5dcd970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index 56693e36e1..b2e855ad6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -468,6 +468,9 @@ 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/cli.js # Optional: Claude Code SDK cli.js path. + # 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' diff --git a/Dockerfile b/Dockerfile index 139b3efaf7..93a537525b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 6c4c827783..a346ccbb96 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index 52c47823c1..010eb64335 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -176,6 +176,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' }, diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2f53879931..8608c6df04 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -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,62 @@ function isCommandAvailable(command: string): boolean { } } +/** + * 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. + */ +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 (existsSync(nativePath)) return nativePath; + + // 2. npm global cli.js + try { + const npmRoot = execSync('npm root -g', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (npmRoot) { + const npmCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js'); + if (existsSync(npmCliJs)) return npmCliJs; + } + } catch { + // fall through to PATH lookup + } + + // 3. Fallback: resolve via `which` / `where` (Homebrew, winget, custom layouts) + 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(); + if (first && existsSync(first)) return first; + } catch { + // no claude on PATH + } + + return null; +} + /** * Get Node.js version if installed, or null if not */ @@ -210,7 +269,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 +412,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 { + 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 +777,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 +786,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 +827,7 @@ After upgrading, run 'archon setup' again.`, claudeAuthType, claudeApiKey, claudeOauthToken, + ...(claudeBinaryPath !== undefined ? { claudeBinaryPath } : {}), codex: hasCodex, codexTokens, defaultAssistant, @@ -1070,6 +1188,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'); } diff --git a/packages/docs-web/src/content/docs/deployment/docker.md b/packages/docs-web/src/content/docs/deployment/docker.md index fc1add6678..e1caf127a7 100644 --- a/packages/docs-web/src/content/docs/deployment/docker.md +++ b/packages/docs-web/src/content/docs/deployment/docker.md @@ -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) diff --git a/packages/docs-web/src/content/docs/deployment/local.md b/packages/docs-web/src/content/docs/deployment/local.md index 2e3c9f9618..5f4553ba77 100644 --- a/packages/docs-web/src/content/docs/deployment/local.md +++ b/packages/docs-web/src/content/docs/deployment/local.md @@ -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 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 c856c9ccd4..b7eb80888f 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 @@ -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 diff --git a/packages/docs-web/src/content/docs/getting-started/configuration.md b/packages/docs-web/src/content/docs/getting-started/configuration.md index ec836f1202..5a8588e1fa 100644 --- a/packages/docs-web/src/content/docs/getting-started/configuration.md +++ b/packages/docs-web/src/content/docs/getting-started/configuration.md @@ -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` | diff --git a/packages/docs-web/src/content/docs/getting-started/installation.md b/packages/docs-web/src/content/docs/getting-started/installation.md index 4af7ba9aff..20bf4eb32b 100644 --- a/packages/docs-web/src/content/docs/getting-started/installation.md +++ b/packages/docs-web/src/content/docs/getting-started/installation.md @@ -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 diff --git a/packages/docs-web/src/content/docs/getting-started/overview.md b/packages/docs-web/src/content/docs/getting-started/overview.md index f1d58ae402..cee57df09d 100644 --- a/packages/docs-web/src/content/docs/getting-started/overview.md +++ b/packages/docs-web/src/content/docs/getting-started/overview.md @@ -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: diff --git a/packages/docs-web/src/content/docs/getting-started/quick-start.md b/packages/docs-web/src/content/docs/getting-started/quick-start.md index 58a76a62b2..529bf6026d 100644 --- a/packages/docs-web/src/content/docs/getting-started/quick-start.md +++ b/packages/docs-web/src/content/docs/getting-started/quick-start.md @@ -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 diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 900b8c0313..3fa335530e 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -60,12 +60,16 @@ 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 SDK's cli.js. + # Required in compiled Archon binaries when CLAUDE_BIN_PATH is not set. + # claudeBinaryPath: /absolute/path/to/cli.js 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: diff --git a/packages/providers/package.json b/packages/providers/package.json index cbe4a4617a..fb28d256cc 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -16,7 +16,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": { diff --git a/packages/providers/src/claude/binary-resolver-dev.test.ts b/packages/providers/src/claude/binary-resolver-dev.test.ts new file mode 100644 index 0000000000..2474c76d73 --- /dev/null +++ b/packages/providers/src/claude/binary-resolver-dev.test.ts @@ -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; + } + } + }); +}); diff --git a/packages/providers/src/claude/binary-resolver.test.ts b/packages/providers/src/claude/binary-resolver.test.ts new file mode 100644 index 0000000000..f87e78f36d --- /dev/null +++ b/packages/providers/src/claude/binary-resolver.test.ts @@ -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; + + 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'); + }); +}); diff --git a/packages/providers/src/claude/binary-resolver.ts b/packages/providers/src/claude/binary-resolver.ts new file mode 100644 index 0000000000..f236acb277 --- /dev/null +++ b/packages/providers/src/claude/binary-resolver.ts @@ -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 | undefined; +function getLog(): ReturnType { + 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 { + 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); +} diff --git a/packages/providers/src/claude/config.ts b/packages/providers/src/claude/config.ts index 3dca726e5f..33b33209ee 100644 --- a/packages/providers/src/claude/config.ts +++ b/packages/providers/src/claude/config.ts @@ -27,5 +27,9 @@ export function parseClaudeConfig(raw: Record): ClaudeProviderD } } + if (typeof raw.claudeBinaryPath === 'string') { + result.claudeBinaryPath = raw.claudeBinaryPath; + } + return result; } diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index b4769e66ec..e1f141ffdb 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -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'; @@ -510,11 +516,14 @@ function buildBaseClaudeOptions( controller: AbortController, stderrLines: string[], toolResultQueue: ToolResultEntry[], - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + cliPath: string | undefined ): Options { return { cwd, - pathToClaudeCodeExecutable: cliPath, + // In compiled binaries, the resolver supplies an absolute cli.js path; + // in dev mode it returns undefined and the SDK resolves from node_modules. + ...(cliPath !== undefined ? { 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'], @@ -840,6 +849,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 +893,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 +901,8 @@ export class ClaudeProvider implements IAgentProvider { controller, stderrLines, toolResultQueue, - env + env, + resolvedCliPath ); // 2. Apply nodeConfig translation (re-applied per attempt since options are fresh) diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 435073d745..330669e0c5 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -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 { From 0fdf140208d09d3079c648bd9a0e35f4030a37d2 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 16:18:08 +0300 Subject: [PATCH 2/6] fix(providers): only pass --no-env-file when spawning Claude via Bun/Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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. --- packages/providers/src/claude/provider.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index e1f141ffdb..b7534dc7cf 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -519,14 +519,20 @@ function buildBaseClaudeOptions( env: NodeJS.ProcessEnv, cliPath: string | undefined ): Options { + // `--no-env-file` is a Bun flag that prevents auto-loading `.env` from the + // target repo cwd into the Claude Code subprocess. It only applies when the + // subprocess is spawned through Bun/Node (i.e. the executable is a `.js` + // file). For a native Claude Code binary (from the curl/PowerShell + // installer), the flag is passed directly to the binary, which rejects + // unknown options. We emit `executableArgs` only when targeting a JS file. + const isJsExecutable = cliPath === undefined || cliPath.endsWith('.js'); + return { cwd, - // In compiled binaries, the resolver supplies an absolute cli.js path; + // 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 } : {}), - // Prevent Bun from auto-loading .env from the target repo cwd. - // Without this, the Claude Code subprocess inherits repo secrets. - executableArgs: ['--no-env-file'], + ...(isJsExecutable ? { executableArgs: ['--no-env-file'] } : {}), env, model: requestOptions?.model ?? assistantDefaults.model, abortController: controller, From 357d8cc695d51f81381bf0a43a94b6093dfaef00 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 16:25:06 +0300 Subject: [PATCH 3/6] 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. --- packages/providers/src/claude/provider.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index b7534dc7cf..4bedd84a20 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -521,10 +521,17 @@ function buildBaseClaudeOptions( ): Options { // `--no-env-file` is a Bun flag that prevents auto-loading `.env` from the // target repo cwd into the Claude Code subprocess. It only applies when the - // subprocess is spawned through Bun/Node (i.e. the executable is a `.js` - // file). For a native Claude Code binary (from the curl/PowerShell - // installer), the flag is passed directly to the binary, which rejects - // unknown options. We emit `executableArgs` only when targeting a JS file. + // subprocess is spawned through Bun/Node (executable is a `.js` file). For + // a native Claude Code binary (curl/PowerShell installer), the flag is + // passed directly to the binary, which rejects unknown options. + // + // Dropping the flag for native binaries is verified safe — the native + // binary does not auto-load `.env` from CWD (probed end-to-end with a + // 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. const isJsExecutable = cliPath === undefined || cliPath.endsWith('.js'); return { From 73b012b64e0667b56821b4272eb0dc1386f4cc5c Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 16:38:10 +0300 Subject: [PATCH 4/6] =?UTF-8?q?chore(providers):=20cleanup=20pass=20?= =?UTF-8?q?=E2=80=94=20exports,=20docs,=20troubleshooting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../content/docs/reference/configuration.md | 6 ++-- .../content/docs/reference/troubleshooting.md | 35 +++++++++++++++++++ packages/providers/package.json | 1 + packages/providers/src/index.ts | 3 +- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 3fa335530e..42e5a0609b 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -60,9 +60,11 @@ 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 SDK's cli.js. + # Optional: absolute path to the Claude Code executable. # Required in compiled Archon binaries when CLAUDE_BIN_PATH is not set. - # claudeBinaryPath: /absolute/path/to/cli.js + # 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 diff --git a/packages/docs-web/src/content/docs/reference/troubleshooting.md b/packages/docs-web/src/content/docs/reference/troubleshooting.md index 2c866166db..5e9b032293 100644 --- a/packages/docs-web/src/content/docs/reference/troubleshooting.md +++ b/packages/docs-web/src/content/docs/reference/troubleshooting.md @@ -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. diff --git a/packages/providers/package.json b/packages/providers/package.json index fb28d256cc..9e4e278b8e 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -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", diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index e24bb630eb..7f0d20d998 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -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'; From 44ec6e3023b20e1d2abaa6efbe34fc6a056b200d Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 16:43:24 +0300 Subject: [PATCH 5/6] 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). --- CLAUDE.md | 4 +- packages/cli/src/commands/setup.test.ts | 76 +++++++++++++++++++ packages/cli/src/commands/setup.ts | 73 ++++++++++++------ .../providers/src/claude/provider.test.ts | 32 +++++++- packages/providers/src/claude/provider.ts | 46 +++++++---- 5 files changed, 190 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2e855ad6e..53c1f20c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -468,7 +468,9 @@ 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/cli.js # Optional: Claude Code SDK cli.js path. + 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: diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index 010eb64335..18a10f2793 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -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()); @@ -453,3 +455,77 @@ 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; + let npmRootSpy: ReturnType; + let whichSpy: ReturnType; + + 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)', () => { + npmRootSpy.mockReturnValue('/fake/npm/root'); + fileExistsSpy.mockImplementation((p: string) => p.endsWith('@anthropic-ai/claude-code/cli.js')); + const result = detectClaudeExecutablePath(); + expect(result).toBe('/fake/npm/root/@anthropic-ai/claude-code/cli.js'); + // 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(); + }); +}); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 8608c6df04..e428d8c6f7 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -163,6 +163,43 @@ 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. * @@ -179,42 +216,28 @@ function isCommandAvailable(command: string): boolean { * * 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`). */ -function detectClaudeExecutablePath(): string | null { +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 (existsSync(nativePath)) return nativePath; + if (probeFileExists(nativePath)) return nativePath; // 2. npm global cli.js - try { - const npmRoot = execSync('npm root -g', { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (npmRoot) { - const npmCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js'); - if (existsSync(npmCliJs)) return npmCliJs; - } - } catch { - // fall through to PATH lookup + 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) - 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(); - if (first && existsSync(first)) return first; - } catch { - // no claude on PATH - } + const fromPath = probeWhichClaude(); + if (fromPath && probeFileExists(fromPath)) return fromPath; return null; } diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index 1b9ed947dd..16641b1555 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -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; diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 4bedd84a20..26935bf373 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -505,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. @@ -519,20 +546,11 @@ function buildBaseClaudeOptions( env: NodeJS.ProcessEnv, cliPath: string | undefined ): Options { - // `--no-env-file` is a Bun flag that prevents auto-loading `.env` from the - // target repo cwd into the Claude Code subprocess. It only applies when the - // subprocess is spawned through Bun/Node (executable is a `.js` file). For - // a native Claude Code binary (curl/PowerShell installer), the flag is - // passed directly to the binary, which rejects unknown options. - // - // Dropping the flag for native binaries is verified safe — the native - // binary does not auto-load `.env` from CWD (probed end-to-end with a - // 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. - const isJsExecutable = cliPath === undefined || cliPath.endsWith('.js'); + const isJsExecutable = shouldPassNoEnvFile(cliPath); + getLog().debug( + { cliPath: cliPath ?? null, isJsExecutable, passesNoEnvFile: isJsExecutable }, + 'claude.subprocess_env_file_flag' + ); return { cwd, From 556879be64c94b34f14021e78ccd66b2871ace3e Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 14 Apr 2026 17:50:07 +0300 Subject: [PATCH 6/6] 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. --- packages/cli/src/commands/setup.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index 18a10f2793..6d463d5fda 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -489,10 +489,15 @@ describe('detectClaudeExecutablePath probe order', () => { }); it('falls through to npm cli.js when native is missing (tier 2 wins)', () => { - npmRootSpy.mockReturnValue('/fake/npm/root'); - fileExistsSpy.mockImplementation((p: string) => p.endsWith('@anthropic-ai/claude-code/cli.js')); + // 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('/fake/npm/root/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe(expectedCliJs); // Tier 3 must not have been consulted. expect(whichSpy).not.toHaveBeenCalled(); });