diff --git a/apps/agent-worker/src/agent-events.ts b/apps/agent-worker/src/agent-events.ts index 7dbec21..e5663fd 100644 --- a/apps/agent-worker/src/agent-events.ts +++ b/apps/agent-worker/src/agent-events.ts @@ -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[] => { diff --git a/apps/agent-worker/src/runtime/auth.ts b/apps/agent-worker/src/runtime/auth.ts new file mode 100644 index 0000000..cec04b5 --- /dev/null +++ b/apps/agent-worker/src/runtime/auth.ts @@ -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 }); +}; diff --git a/apps/agent-worker/src/runtime/claude-adapter.ts b/apps/agent-worker/src/runtime/claude-adapter.ts new file mode 100644 index 0000000..50a5839 --- /dev/null +++ b/apps/agent-worker/src/runtime/claude-adapter.ts @@ -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, + ): Promise => { + 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 ` 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 }; +}; diff --git a/apps/agent-worker/src/runtime/provider.ts b/apps/agent-worker/src/runtime/provider.ts index 37580df..4b4c705 100644 --- a/apps/agent-worker/src/runtime/provider.ts +++ b/apps/agent-worker/src/runtime/provider.ts @@ -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 => { + 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 }; +}; diff --git a/apps/agent-worker/src/runtime/register.ts b/apps/agent-worker/src/runtime/register.ts index bc8def2..7a71ac7 100644 --- a/apps/agent-worker/src/runtime/register.ts +++ b/apps/agent-worker/src/runtime/register.ts @@ -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); diff --git a/apps/agent-worker/tests/unit/agent-events.test.ts b/apps/agent-worker/tests/unit/agent-events.test.ts index a55066e..075e9e7 100644 --- a/apps/agent-worker/tests/unit/agent-events.test.ts +++ b/apps/agent-worker/tests/unit/agent-events.test.ts @@ -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', + }); + }); }); diff --git a/apps/agent-worker/tests/unit/claude-adapter.test.ts b/apps/agent-worker/tests/unit/claude-adapter.test.ts new file mode 100644 index 0000000..fd8f6d2 --- /dev/null +++ b/apps/agent-worker/tests/unit/claude-adapter.test.ts @@ -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 => { + 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'"); + }); +});