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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions homebrew/archon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "archon",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"workspaces": [
"packages/*"
Expand Down
40 changes: 37 additions & 3 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }],
Expand All @@ -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',
Expand All @@ -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<ReturnType<typeof configLoader.loadConfig>>);
Expand All @@ -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')
);
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -315,6 +324,7 @@ export class ClaudeClient implements IAssistantClient {

const options: Options = {
cwd,
pathToClaudeCodeExecutable: cliPath,
env: requestOptions?.env
? { ...buildSubprocessEnv(), ...requestOptions.env }
: buildSubprocessEnv(),
Expand Down
29 changes: 24 additions & 5 deletions packages/core/src/clients/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }],
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading