diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4d032f52..0c430f0f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.2] - 2026-04-08 + +Critical hotfix: compiled binaries could not spawn Claude. Also fixes an env-leak gate false-positive for unregistered working directories. + +### Fixed + +- **Claude SDK spawn in compiled binaries**: the Claude Agent SDK was resolving its `cli.js` via `import.meta.url` of the bundled module, which `bun build --compile` freezes at build time to the build host's absolute `node_modules` path. Every binary shipped from CI carried a `/Users/runner/work/Archon/...` path that existed only on the GitHub Actions runner, and every `workflow run` hit `Module not found` after three retries. Now imports `@anthropic-ai/claude-agent-sdk/embed` so `cli.js` is embedded into the binary's `$bunfs` and extracted to a real temp path at runtime (#990). +- **Env-leak gate false-positive for unregistered cwd**: pre-spawn scan now skips cwd paths that aren't registered as codebases instead of blocking the workflow (#991, #992). + ## [0.3.1] - 2026-04-08 Patch release: SQLite migration fix for existing databases and release build pipeline fix. diff --git a/homebrew/archon.rb b/homebrew/archon.rb index d52f4d42aa..340cc6fd7f 100644 --- a/homebrew/archon.rb +++ b/homebrew/archon.rb @@ -7,28 +7,28 @@ class Archon < Formula desc "Remote agentic coding platform - control AI assistants from anywhere" homepage "https://github.com/coleam00/Archon" - version "0.3.0" + version "0.3.1" license "MIT" on_macos do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" - sha256 "2ff39add5306d839b28e05e58a98442a55d7b1a27d3045999ca62e9ccc7557b9" + sha256 "2538346f483d9351d6738a0ef9299e0f475d402005c61286c2557ce41d8a47b9" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" - sha256 "7d5719a00e95d05303e0fd2586f6d69c41102bde1e23b11aa7e662905c235100" + sha256 "2eb2c2b8a502270c5d049316f4a2e1314348df3e9112f36953132e3c2a2c67ce" end end on_linux do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" - sha256 "8bf7c0a335455b10f7362902d78b2b9a90778d4d2e979153ab5b114d4edb996c" + sha256 "12c135688310ba0c0c334832f69418d93a13d50c4013ad84803b05cb776f14be" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" - sha256 "f1f730ebea4d77e6fa533a8fbdd194fb356ddc441160fae5f63f6225c27ff8fc" + sha256 "071d8d1bb64e30a60a2c1514d581747a8f27d2fbe1cc33bcc66426b8265f3a65" end end diff --git a/package.json b/package.json index f25d9ceb76..c93183dfef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "archon", - "version": "0.3.1", + "version": "0.3.2", "private": true, "workspaces": [ "packages/*" diff --git a/packages/core/src/clients/claude.test.ts b/packages/core/src/clients/claude.test.ts index fd99746e03..fd79d16280 100644 --- a/packages/core/src/clients/claude.test.ts +++ b/packages/core/src/clients/claude.test.ts @@ -978,7 +978,12 @@ describe('ClaudeClient', () => { spyScan.mockRestore(); }); - test('throws EnvLeakError when .env contains sensitive keys and codebase has no consent', async () => { + test('throws EnvLeakError when .env contains sensitive keys and registered codebase has no consent', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); spyScan.mockReturnValueOnce({ path: '/workspace', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], @@ -991,6 +996,24 @@ describe('ClaudeClient', () => { }).toThrow('Cannot run workflow'); }); + test('skips scan entirely when cwd is not a registered codebase', async () => { + // Both lookups return null (default from beforeEach) → unregistered cwd. + // Even if sensitive keys would be present, the pre-spawn check must not run + // because the canonical gate is registerRepoAtPath, not sendQuery. + spyScan.mockReturnValue({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(spyScan).not.toHaveBeenCalled(); + expect(chunks).toHaveLength(1); + }); + test('skips scan when codebase has allow_env_keys: true', async () => { spyFindByDefaultCwd.mockResolvedValueOnce({ id: 'codebase-1', @@ -1007,17 +1030,23 @@ describe('ClaudeClient', () => { expect(chunks).toHaveLength(1); }); - test('proceeds when cwd has no registered codebase and no sensitive keys', async () => { + test('proceeds without scanning when cwd has no registered codebase', async () => { + // Unregistered cwd — the pre-spawn safety net is out of scope. const chunks = []; for await (const chunk of client.sendQuery('test', '/workspace')) { chunks.push(chunk); } - expect(spyScan).toHaveBeenCalledTimes(1); + expect(spyScan).not.toHaveBeenCalled(); expect(chunks).toHaveLength(1); }); test('skips scan when allowTargetRepoKeys is true in merged config', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockResolvedValueOnce({ allowTargetRepoKeys: true, } as Awaited>); @@ -1038,6 +1067,11 @@ describe('ClaudeClient', () => { }); test('falls back to scanner when loadConfig throws (fail-closed)', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockRejectedValueOnce( new Error('YAML parse error') ); diff --git a/packages/core/src/clients/claude.ts b/packages/core/src/clients/claude.ts index 0a0624c69c..1d2bd664b3 100644 --- a/packages/core/src/clients/claude.ts +++ b/packages/core/src/clients/claude.ts @@ -19,6 +19,15 @@ import { type HookCallback, type HookCallbackMatcher, } from '@anthropic-ai/claude-agent-sdk'; +// The `/embed` entry point uses `import ... with { type: 'file' }` to embed +// the SDK's `cli.js` into the compiled binary's $bunfs virtual filesystem, +// then extracts it to a temp path at runtime so the subprocess can exec it. +// Without this, the SDK falls back to resolving `cli.js` from +// `import.meta.url` of its own module — which bun freezes at build time to +// the build host's absolute node_modules path, producing a "Module not found +// /Users/runner/..." error on any machine other than the CI runner. +// Safe in dev too: resolves to the real on-disk cli.js. +import cliPath from '@anthropic-ai/claude-agent-sdk/embed'; import { type AssistantRequestOptions, type IAssistantClient, @@ -267,7 +276,7 @@ export class ClaudeClient implements IAssistantClient { const codebase = (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); - if (!codebase?.allow_env_keys) { + if (codebase && !codebase.allow_env_keys) { // Fail-closed: a config load failure (corrupt YAML, permission denied) // must NOT silently bypass the gate. Catch, log, and treat as // `allowTargetRepoKeys = false` so the scanner still runs. @@ -315,6 +324,7 @@ export class ClaudeClient implements IAssistantClient { const options: Options = { cwd, + pathToClaudeCodeExecutable: cliPath, env: requestOptions?.env ? { ...buildSubprocessEnv(), ...requestOptions.env } : buildSubprocessEnv(), diff --git a/packages/core/src/clients/codex.test.ts b/packages/core/src/clients/codex.test.ts index e29002cd0a..cfa329e7c1 100644 --- a/packages/core/src/clients/codex.test.ts +++ b/packages/core/src/clients/codex.test.ts @@ -1029,9 +1029,12 @@ describe('CodexClient', () => { spyScan.mockRestore(); }); - test('throws EnvLeakError when .env contains sensitive keys and codebase has no consent', async () => { - spyFindByDefaultCwd.mockResolvedValueOnce(null); - spyFindByPathPrefix.mockResolvedValueOnce(null); + test('throws EnvLeakError when .env contains sensitive keys and registered codebase has no consent', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); spyScan.mockReturnValueOnce({ path: '/workspace', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], @@ -1046,6 +1049,22 @@ describe('CodexClient', () => { await expect(consumeGenerator()).rejects.toThrow('Cannot run workflow'); }); + test('skips scan entirely when cwd is not a registered codebase', async () => { + // Both lookups return null (default from beforeEach). Pre-spawn safety net + // is only for registered codebases; unregistered paths go through registerRepoAtPath. + spyScan.mockReturnValue({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(spyScan).not.toHaveBeenCalled(); + }); + test('skips scan when codebase has allow_env_keys: true', async () => { spyFindByDefaultCwd.mockResolvedValueOnce({ id: 'codebase-1', @@ -1061,13 +1080,13 @@ describe('CodexClient', () => { expect(spyScan).not.toHaveBeenCalled(); }); - test('proceeds when cwd has no registered codebase and no sensitive keys', async () => { + test('proceeds without scanning when cwd has no registered codebase', async () => { const chunks = []; for await (const chunk of client.sendQuery('test', '/workspace')) { chunks.push(chunk); } - expect(spyScan).toHaveBeenCalledTimes(1); + expect(spyScan).not.toHaveBeenCalled(); }); test('uses prefix lookup for worktree paths when exact match returns null', async () => { diff --git a/packages/core/src/clients/codex.ts b/packages/core/src/clients/codex.ts index a7c52731e1..110f35d2b2 100644 --- a/packages/core/src/clients/codex.ts +++ b/packages/core/src/clients/codex.ts @@ -163,7 +163,7 @@ export class CodexClient implements IAssistantClient { const codebase = (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); - if (!codebase?.allow_env_keys) { + if (codebase && !codebase.allow_env_keys) { // Fail-closed: a config load failure must NOT silently bypass the gate. let allowTargetRepoKeys = false; try {