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 }; };