feat(worker): ClaudeCodeAdapter runs claude -p stream-json in the per-user box

This commit is contained in:
Gabriel Brown
2026-07-11 10:35:56 -04:00
parent c9ee2e6cde
commit 76886621b5
7 changed files with 465 additions and 0 deletions
+108
View File
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
return events;
};
// Claude Code `--output-format stream-json` emits one JSON object per line:
// `system:init` announces the session id, `assistant` messages carry text and
// tool_use blocks, `user` messages carry tool_result blocks, and `result`
// terminates the turn (success with final text, or an `error_*` subtype).
export const normalizeClaudeJsonLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const event = asRecord(parsed);
if (!event) return [{ kind: 'status', status: line }];
const type = stringify(event.type);
const subtype = stringify(event.subtype);
const events: NormalizedAgentEvent[] = [];
if (type === 'system') {
const sessionId = stringify(event.session_id);
if (subtype === 'init' && sessionId) {
events.push({ kind: 'session', sessionId });
}
if (events.length === 0) {
events.push({ kind: 'status', status: subtype || 'system' });
}
return events;
}
if (type === 'assistant') {
const message = asRecord(event.message);
const content = message?.content;
let text = '';
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record) continue;
const blockType = stringify(record.type);
if (blockType === 'text') {
text += textFromPart(record);
} else if (blockType === 'tool_use') {
events.push({
kind: 'tool_started',
name: stringify(record.name ?? 'tool'),
input: JSON.stringify(record.input ?? {}),
});
}
}
}
if (text) events.unshift({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: 'assistant' });
}
return events;
}
if (type === 'content_block_delta') {
const delta = asRecord(event.delta);
const text = delta ? textFromPart(delta) : '';
if (text) events.push({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: type });
}
return events;
}
if (type === 'user') {
const message = asRecord(event.message);
const content = message?.content;
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record || stringify(record.type) !== 'tool_result') continue;
events.push({
kind: 'tool_completed',
name: 'tool',
output: toolOutputFromRecord(record),
});
}
}
if (events.length === 0) {
events.push({ kind: 'status', status: 'user' });
}
return events;
}
if (type === 'result') {
const sessionId = stringify(event.session_id);
if (sessionId) events.push({ kind: 'session', sessionId });
if (subtype === 'success') {
events.push({
kind: 'assistant_completed',
content: stringify(event.result),
});
} else {
events.push({
kind: 'error',
message: stringify(event.result ?? event.error ?? subtype),
});
}
return events;
}
return [{ kind: 'status', status: type || line }];
};
export const normalizeOpenCodeEvent = (
input: unknown,
): NormalizedAgentEvent[] => {
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
// permissions. Shared by the credential-writing adapters (Claude, and available
// to Codex/OpenCode). `label` names the source in the parse-error message.
export const writeJsonFile = async (
filePath: string,
content: string,
label = 'auth',
) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error(`${label} JSON is not valid JSON.`);
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
@@ -0,0 +1,127 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { writeJsonFile } from './auth';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
// environment; only OAuth-json profiles need `.credentials.json` on disk.
if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Claude auth profile is missing credentials.json contents.');
}
const credentialsPath = path.join(
workspace.homeDir,
'.claude',
'.credentials.json',
);
await writeJsonFile(credentialsPath, secret, 'Claude auth');
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'claude';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// NOTE: exact flags are UNVERIFIED against the pinned CLI and are confirmed
// in Task 11's smoke. `--verbose` is required for `stream-json`;
// `--resume <sessionId>` carries continuity.
const argv = [
'claude',
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--model',
claudeModel(workspace.claim),
...(workspace.claudeSessionId
? ['--resume', workspace.claudeSessionId]
: []),
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeClaudeJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'claude', prepareAuth, runTurn, abort };
};
+28
View File
@@ -169,3 +169,31 @@ export const codexModel = (claim: Claim) => {
export const codexModelArgs = (claim: Claim) =>
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
// Claude Code OAuth profiles store their credentials as a JSON blob written to
// `~/.claude/.credentials.json`; API-key profiles authenticate via
// ANTHROPIC_API_KEY. Mirrors the OpenCode adapter's `opencode_auth_json` signal.
export const isClaudeOAuthProfile = (claim: Claim) =>
claim.aiProviderProfile?.authType === 'opencode_auth_json';
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
// so strip any `provider/` prefix like codexModel does.
export const claudeModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
// Environment for a `claude -p` turn. OAuth-json profiles authenticate from the
// on-disk credentials file (only HOME is needed); API-key profiles export
// ANTHROPIC_API_KEY.
export const claudeEnv = (
claim: Claim,
containerHome: string,
): Record<string, string> => {
if (isClaudeOAuthProfile(claim)) return { HOME: containerHome };
const secret = claim.aiProviderProfile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No Anthropic API key is configured for this Claude job.');
}
return { ANTHROPIC_API_KEY: secret, HOME: containerHome };
};
@@ -1,6 +1,8 @@
import { registerAdapter } from './agent-runtime';
import { createClaudeAdapter } from './claude-adapter';
import { createCodexAdapter } from './codex-adapter';
import { createOpenCodeAdapter } from './opencode-adapter';
registerAdapter('codex', createCodexAdapter);
registerAdapter('opencode', createOpenCodeAdapter);
registerAdapter('claude', createClaudeAdapter);
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
normalizeClaudeJsonLine,
normalizeCodexJsonLine,
normalizeOpenCodeEvent,
normalizeOpenCodeRunLine,
@@ -329,4 +330,73 @@ describe('agent event normalization', () => {
status: 'not json at all',
});
});
test('normalizes Claude Code stream-json output lines', () => {
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
},
}),
),
).toContainEqual({ kind: 'assistant_delta', content: 'hello world' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'Bash',
input: JSON.stringify({ command: 'ls' }),
});
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'assistant_completed', content: 'final text' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
result: 'hit the turn limit',
}),
),
).toContainEqual({ kind: 'error', message: 'hit the turn limit' });
expect(normalizeClaudeJsonLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
});
@@ -0,0 +1,110 @@
import { describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/claude-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'claude-sonnet-4', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType: 'api_key',
secret: 'sk-ant-test',
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-claude-adapter-test-missing',
homeDir: '/tmp/spoon-claude-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
describe('ClaudeCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createClaudeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'hi there' }] },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi there',
});
expect(result.finalMessage).toBe('final text');
expect(result.sessionId).toBe('abc');
expect(result.error).toBeUndefined();
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// `claude -p` argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'claude' '-p'");
expect(command[3]).toContain("'--output-format' 'stream-json'");
});
});