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
7 changes: 4 additions & 3 deletions .claude/rules/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ bun run cli version

## Startup Behavior

1. Loads `~/.archon/.env` with `override: true` (Archon's config wins over any Bun-auto-loaded CWD vars)
2. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true`
3. Imports all commands AFTER dotenv setup
1. `@archon/paths/strip-cwd-env-boot` (first import) removes all Bun-auto-loaded CWD `.env` keys from `process.env`
2. Loads `~/.archon/.env` with `override: true` (Archon config wins over shell-inherited vars)
3. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true`
4. Imports all commands AFTER dotenv setup

## WorkflowRunOptions Interface

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
### Architecture Layers

**Package Split:**
- **@archon/paths**: Path resolution utilities, Pino logger factory, web dist cache path (`getWebDistDir`) (no @archon/* deps)
- **@archon/paths**: Path resolution utilities, Pino logger factory, web dist cache path (`getWebDistDir`), CWD env stripper (`stripCwdEnv`, `strip-cwd-env-boot`) (no @archon/* deps; `pino` and `dotenv` are allowed external deps)
- **@archon/git**: Git operations - worktrees, branches, repos, exec wrappers (depends only on @archon/paths)
- **@archon/isolation**: Worktree isolation types, providers, resolver, error classifiers (depends only on @archon/git + @archon/paths)
- **@archon/workflows**: Workflow engine - loader, router, executor, DAG, logger, bundled defaults (depends only on @archon/git + @archon/paths + @hono/zod-openapi + zod; DB/AI/config injected via `WorkflowDeps`)
Expand Down
21 changes: 11 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 10 additions & 7 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@
* archon workflow run <name> [msg] Run a workflow
* archon version Show version info
*/
// Must be the very first import — strips Bun-auto-loaded CWD .env keys before
// any module reads process.env at init time (e.g. @archon/paths/logger reads LOG_LEVEL).
import '@archon/paths/strip-cwd-env-boot';
import { parseArgs } from 'util';
import { config } from 'dotenv';
import { resolve } from 'path';
import { existsSync } from 'fs';

// Load .env from global Archon config (override: true so ~/.archon/.env
// always wins over any Bun-auto-loaded CWD vars).
//
// Credential safety: target repo .env keys that Bun auto-loads from CWD
// cannot leak into AI subprocesses — SUBPROCESS_ENV_ALLOWLIST blocks them.
// The env-leak gate provides a second layer by scanning target repos before
// spawning. No CWD stripping needed.
// Load ~/.archon/.env with override: true — Archon-specific config must win
// over shell-inherited env vars (e.g. PORT, LOG_LEVEL from shell profile).
// CWD .env keys are already gone (stripCwdEnv above), so override only
// affects shell-inherited values, which is the intended behavior.
const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env');
if (existsSync(globalEnvPath)) {
const result = config({ path: globalEnvPath, override: true });
Expand All @@ -30,6 +30,9 @@ if (existsSync(globalEnvPath)) {
}
}

// CLAUDECODE=1 warning is emitted inside stripCwdEnv() (boot import above)
// BEFORE the marker is deleted from process.env. No duplicate warning here.

// Smart defaults for Claude auth
// If no explicit tokens, default to global auth from `claude /login`
if (!process.env.CLAUDE_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export async function serveCommand(opts: ServeOptions): Promise<number> {
await startServer({
webDistPath: webDistDir,
port: opts.port,
skipPlatformAdapters: true,
});
} catch (err) {
const error = toError(err);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"./state/*": "./src/state/*.ts"
},
"scripts": {
"test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"type-check": "bun x tsc --noEmit",
"build": "echo 'No build needed - Bun runs TypeScript directly'"
},
Expand Down
205 changes: 97 additions & 108 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,12 @@ describe('ClaudeClient', () => {
);
});

test('strips NODE_OPTIONS from subprocess env', async () => {
const original = process.env.NODE_OPTIONS;
process.env.NODE_OPTIONS = '--inspect';
test('subprocess env passes through all process.env keys (no allowlist filtering)', async () => {
// With the allowlist removed, buildSubprocessEnv returns { ...process.env }.
// CWD .env leakage and CLAUDECODE markers are handled at entry point by
// stripCwdEnv(), not by buildSubprocessEnv(). See #1067, #1097.
const originalKey = process.env.CUSTOM_USER_KEY;
process.env.CUSTOM_USER_KEY = 'user-trusted-value';

mockQuery.mockImplementation(async function* () {
// Empty generator
Expand All @@ -460,113 +463,13 @@ describe('ClaudeClient', () => {
}

const callArgs = mockQuery.mock.calls[0][0] as { options: { env: NodeJS.ProcessEnv } };
expect(callArgs.options.env.NODE_OPTIONS).toBeUndefined();
expect(callArgs.options.env.CUSTOM_USER_KEY).toBe('user-trusted-value');
expect(callArgs.options.env.PATH).toBe(process.env.PATH);
expect(callArgs.options.env.HOME).toBe(process.env.HOME);

// Cleanup
if (original !== undefined) {
process.env.NODE_OPTIONS = original;
} else {
delete process.env.NODE_OPTIONS;
}
});

test('ANTHROPIC_API_KEY alone does not set hasExplicitTokens (falls through to global auth)', async () => {
const originalOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const originalApiKey = process.env.CLAUDE_API_KEY;
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;

delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
delete process.env.CLAUDE_API_KEY;
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';

mockQuery.mockImplementation(async function* () {
// Empty generator
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}

// ANTHROPIC_API_KEY must NOT reach the subprocess: it is not in the
// SUBPROCESS_ENV_ALLOWLIST, so a leaked target-repo key cannot bill
// the wrong account. See issue #1029.
const callArgs = mockQuery.mock.calls[0][0] as { options: { env: NodeJS.ProcessEnv } };
expect(callArgs.options.env.ANTHROPIC_API_KEY).toBeUndefined();
// Explicit SDK vars are absent (useGlobalAuth=true path)
expect(callArgs.options.env.CLAUDE_API_KEY).toBeUndefined();
expect(callArgs.options.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();

// Cleanup
if (originalOauth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOauth;
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (originalApiKey !== undefined) process.env.CLAUDE_API_KEY = originalApiKey;
else delete process.env.CLAUDE_API_KEY;
if (originalAnthropicKey !== undefined) process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
else delete process.env.ANTHROPIC_API_KEY;
});

test('ANTHROPIC_API_KEY excluded from subprocess env when using explicit auth (useGlobalAuth=false)', async () => {
const originalOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const originalApiKey = process.env.CLAUDE_API_KEY;
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
const originalGlobalAuth = process.env.CLAUDE_USE_GLOBAL_AUTH;

// Force explicit auth path regardless of env
process.env.CLAUDE_USE_GLOBAL_AUTH = 'false';
process.env.CLAUDE_API_KEY = 'sk-ant-explicit-key';
process.env.ANTHROPIC_API_KEY = 'sk-ant-target-repo-key';
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;

mockQuery.mockImplementation(async function* () {
// Empty generator
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}

// ANTHROPIC_API_KEY must NOT reach the subprocess regardless of which auth
// path is taken — the allowlist excludes it in both cases. See issue #1029.
const callArgs = mockQuery.mock.calls[0][0] as { options: { env: NodeJS.ProcessEnv } };
expect(callArgs.options.env.ANTHROPIC_API_KEY).toBeUndefined();
// Explicit auth vars are present on the useGlobalAuth=false path
expect(callArgs.options.env.CLAUDE_API_KEY).toBeDefined();

// Cleanup
if (originalOauth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOauth;
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (originalApiKey !== undefined) process.env.CLAUDE_API_KEY = originalApiKey;
else delete process.env.CLAUDE_API_KEY;
if (originalAnthropicKey !== undefined) process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
else delete process.env.ANTHROPIC_API_KEY;
if (originalGlobalAuth !== undefined) process.env.CLAUDE_USE_GLOBAL_AUTH = originalGlobalAuth;
else delete process.env.CLAUDE_USE_GLOBAL_AUTH;
});

test('strips VSCODE_INSPECTOR_OPTIONS from subprocess env', async () => {
const original = process.env.VSCODE_INSPECTOR_OPTIONS;
process.env.VSCODE_INSPECTOR_OPTIONS = 'some-value';

mockQuery.mockImplementation(async function* () {
// Empty generator
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}

const callArgs = mockQuery.mock.calls[0][0] as { options: { env: NodeJS.ProcessEnv } };
expect(callArgs.options.env.VSCODE_INSPECTOR_OPTIONS).toBeUndefined();

// Cleanup
if (original !== undefined) {
process.env.VSCODE_INSPECTOR_OPTIONS = original;
} else {
delete process.env.VSCODE_INSPECTOR_OPTIONS;
}
if (originalKey !== undefined) process.env.CUSTOM_USER_KEY = originalKey;
else delete process.env.CUSTOM_USER_KEY;
});

test('classifies exit code errors as crash and retries up to 3 times', async () => {
Expand Down Expand Up @@ -1106,3 +1009,89 @@ describe('ClaudeClient', () => {
});
});
});

describe('withFirstMessageTimeout', () => {
const { withFirstMessageTimeout } = claudeModule;

test('completes normally when first event arrives before timeout', async () => {
async function* fastGen(): AsyncGenerator<string> {
yield 'hello';
yield 'world';
}
const controller = new AbortController();
const gen = withFirstMessageTimeout(fastGen(), controller, 50, {});
const first = await gen.next();
expect(first.value).toBe('hello');
const second = await gen.next();
expect(second.value).toBe('world');
});

test('throws after timeout when generator never yields', async () => {
async function* stuckGen(): AsyncGenerator<string> {
await new Promise(() => {});
yield 'never';
}
const controller = new AbortController();
const gen = withFirstMessageTimeout(stuckGen(), controller, 50, {});
await expect(gen.next()).rejects.toThrow('produced no output within 50ms');
});

test('timeout error mentions issue #1067 for discoverability', async () => {
async function* stuckGen(): AsyncGenerator<string> {
await new Promise(() => {});
yield 'never';
}
const controller = new AbortController();
const gen = withFirstMessageTimeout(stuckGen(), controller, 50, {});
await expect(gen.next()).rejects.toThrow('1067');
});

test('aborts the controller when timeout fires', async () => {
async function* stuckGen(): AsyncGenerator<string> {
await new Promise(() => {});
yield 'never';
}
const controller = new AbortController();
const gen = withFirstMessageTimeout(stuckGen(), controller, 50, {});
await expect(gen.next()).rejects.toThrow();
expect(controller.signal.aborted).toBe(true);
});

test('handles generator that completes immediately without yielding', async () => {
async function* emptyGen(): AsyncGenerator<string> {
return;
}
const controller = new AbortController();
const gen = withFirstMessageTimeout(emptyGen(), controller, 50, {});
const result = await gen.next();
expect(result.done).toBe(true);
});

test('logs diagnostic payload with env keys and process state on timeout', async () => {
async function* stuckGen(): AsyncGenerator<string> {
await new Promise(() => {});
yield 'never';
}
const controller = new AbortController();
const diagnostics = {
subprocessEnvKeys: ['PATH', 'HOME', 'CLAUDE_API_KEY'],
parentClaudeKeys: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
model: 'sonnet',
platform: 'darwin',
};
const gen = withFirstMessageTimeout(stuckGen(), controller, 50, diagnostics);
await expect(gen.next()).rejects.toThrow();

// Verify the diagnostic dump was logged at error level
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
subprocessEnvKeys: ['PATH', 'HOME', 'CLAUDE_API_KEY'],
parentClaudeKeys: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
model: 'sonnet',
platform: 'darwin',
timeoutMs: 50,
}),
'claude.first_event_timeout'
);
});
});
Loading
Loading