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
318 changes: 203 additions & 115 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import { createMockLogger } from '../test/mocks/logger';
import { classifySubprocessError } from './claude';

const mockLogger = createMockLogger();
mock.module('@archon/paths', () => ({
Expand Down Expand Up @@ -953,156 +954,243 @@ describe('ClaudeClient', () => {
expect(chunks).toHaveLength(1);
expect(chunks[0]).toEqual({ type: 'assistant', content: 'Real content' });
});
});

describe('pre-spawn env leak gate', () => {
let spyFindByDefaultCwd: ReturnType<typeof spyOn>;
let spyFindByPathPrefix: ReturnType<typeof spyOn>;
let spyScan: ReturnType<typeof spyOn>;

beforeEach(() => {
spyFindByDefaultCwd = spyOn(codebaseDb, 'findCodebaseByDefaultCwd').mockResolvedValue(null);
spyFindByPathPrefix = spyOn(codebaseDb, 'findCodebaseByPathPrefix').mockResolvedValue(null);
spyScan = spyOn(envLeakScanner, 'scanPathForSensitiveKeys').mockReturnValue({
path: '/workspace',
findings: [],
});
test('classifies stale session as fatal (no retry)', async () => {
const error = new Error('No conversation found');
mockQuery.mockImplementation(async function* () {
yield { type: 'result', session_id: 'sid-gate' };
throw error;
});
});

afterEach(() => {
spyFindByDefaultCwd.mockRestore();
spyFindByPathPrefix.mockRestore();
spyScan.mockRestore();
let thrown: unknown;
const consumeGenerator = async () => {
try {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
} catch (e) {
thrown = e;
throw e;
}
};

await expect(consumeGenerator()).rejects.toThrow(/Claude Code stale session/);
// Stale session should NOT retry - single call
expect(mockQuery).toHaveBeenCalledTimes(1);
// Enriched error must preserve original cause for stack trace diagnostics
expect((thrown as Error).cause).toBeDefined();
});

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'] }],
test('classifies "conversation not found" variant as stale session (no retry)', async () => {
const error = new Error('conversation not found');
mockQuery.mockImplementation(async function* () {
throw error;
});

await expect(async () => {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
let thrown: unknown;
const consumeGenerator = async () => {
try {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
} catch (e) {
thrown = e;
throw e;
}
}).toThrow('Cannot run workflow');
};

await expect(consumeGenerator()).rejects.toThrow(/Claude Code stale session/);
expect(mockQuery).toHaveBeenCalledTimes(1);
expect((thrown as Error).cause).toBeDefined();
});
});

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'] }],
});
describe('classifySubprocessError', () => {
test('classifies "no conversation found" as stale_session', () => {
expect(classifySubprocessError('No conversation found', '')).toBe('stale_session');
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace')) {
chunks.push(chunk);
}
test('classifies stale session case-insensitively', () => {
expect(classifySubprocessError('NO CONVERSATION FOUND', '')).toBe('stale_session');
});

expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
test('classifies "conversation not found" variant as stale_session', () => {
expect(classifySubprocessError('query failed', 'conversation not found')).toBe(
'stale_session'
);
});

test('skips scan when codebase has allow_env_keys: true', async () => {
spyFindByDefaultCwd.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: true,
default_cwd: '/workspace',
});
test('classifies rate_limit correctly', () => {
expect(classifySubprocessError('rate limit exceeded', '')).toBe('rate_limit');
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace')) {
chunks.push(chunk);
}
test('classifies auth errors correctly', () => {
expect(classifySubprocessError('unauthorized', '')).toBe('auth');
});

expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
test('classifies crash correctly', () => {
expect(classifySubprocessError('exited with code 1', '')).toBe('crash');
});

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);
}
test('returns unknown for unrelated errors', () => {
expect(classifySubprocessError('network timeout', '')).toBe('unknown');
});

expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
test('stale_session is checked before crash — overlapping message classifies as stale_session', () => {
// A message containing both a crash token and a stale session token should be stale_session
expect(classifySubprocessError('exited with code 1: no conversation found', '')).toBe(
'stale_session'
);
});

test('skips scan when allowTargetRepoKeys is true in merged config', async () => {
spyFindByDefaultCwd.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: false,
default_cwd: '/workspace',
describe('pre-spawn env leak gate', () => {
let spyFindByDefaultCwd: ReturnType<typeof spyOn>;
let spyFindByPathPrefix: ReturnType<typeof spyOn>;
let spyScan: ReturnType<typeof spyOn>;

beforeEach(() => {
spyFindByDefaultCwd = spyOn(codebaseDb, 'findCodebaseByDefaultCwd').mockResolvedValue(null);
spyFindByPathPrefix = spyOn(codebaseDb, 'findCodebaseByPathPrefix').mockResolvedValue(null);
spyScan = spyOn(envLeakScanner, 'scanPathForSensitiveKeys').mockReturnValue({
path: '/workspace',
findings: [],
});
mockQuery.mockImplementation(async function* () {
yield { type: 'result', session_id: 'sid-gate' };
});
});
const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockResolvedValueOnce({
allowTargetRepoKeys: true,
} as Awaited<ReturnType<typeof configLoader.loadConfig>>);
// Even though scanner would return a finding, the config bypass must short-circuit
spyScan.mockReturnValueOnce({
path: '/workspace',
findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }],

afterEach(() => {
spyFindByDefaultCwd.mockRestore();
spyFindByPathPrefix.mockRestore();
spyScan.mockRestore();
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace')) {
chunks.push(chunk);
}
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'] }],
});

await expect(async () => {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
}).toThrow('Cannot run workflow');
});

expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
spyLoadConfig.mockRestore();
});
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);
}

test('falls back to scanner when loadConfig throws (fail-closed)', async () => {
spyFindByDefaultCwd.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: false,
default_cwd: '/workspace',
expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
});
const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockRejectedValueOnce(
new Error('YAML parse error')
);
spyScan.mockReturnValueOnce({
path: '/workspace',
findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }],

test('skips scan when codebase has allow_env_keys: true', async () => {
spyFindByDefaultCwd.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: true,
default_cwd: '/workspace',
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace')) {
chunks.push(chunk);
}

expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
});

await expect(async () => {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
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);
}
}).toThrow('Cannot run workflow');
expect(spyScan).toHaveBeenCalled();
spyLoadConfig.mockRestore();
});

test('uses prefix lookup for worktree paths when exact match returns null', async () => {
spyFindByPathPrefix.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: true,
default_cwd: '/workspace/source',
expect(spyScan).not.toHaveBeenCalled();
expect(chunks).toHaveLength(1);
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace/worktrees/feature')) {
chunks.push(chunk);
}
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>>);
// Even though scanner would return a finding, the config bypass must short-circuit
spyScan.mockReturnValueOnce({
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);
spyLoadConfig.mockRestore();
});

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')
);
spyScan.mockReturnValueOnce({
path: '/workspace',
findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }],
});

await expect(async () => {
for await (const _ of client.sendQuery('test', '/workspace')) {
// consume
}
}).toThrow('Cannot run workflow');
expect(spyScan).toHaveBeenCalled();
spyLoadConfig.mockRestore();
});

expect(spyFindByPathPrefix).toHaveBeenCalledWith('/workspace/worktrees/feature');
expect(spyScan).not.toHaveBeenCalled();
test('uses prefix lookup for worktree paths when exact match returns null', async () => {
spyFindByPathPrefix.mockResolvedValueOnce({
id: 'codebase-1',
allow_env_keys: true,
default_cwd: '/workspace/source',
});

const chunks = [];
for await (const chunk of client.sendQuery('test', '/workspace/worktrees/feature')) {
chunks.push(chunk);
}

expect(spyFindByPathPrefix).toHaveBeenCalledWith('/workspace/worktrees/feature');
expect(spyScan).not.toHaveBeenCalled();
});
});
});
});
Loading