import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { NormalizedAgentEvent } from '../agent-events'; import { normalizeOpenCodeRunLine } from '../agent-events'; import { env } from '../env'; import type { AdapterFactory, TurnResult } from './agent-runtime'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; import type { AdapterWorkspace } from './provider'; import { opencodeModel, providerEnvironment } from './provider'; // Normalize + pretty-print an auth JSON blob before writing it with owner-only // permissions (mirrors the CodexAdapter helper). const writeJsonFile = async (filePath: string, content: string) => { let normalized = content.trim(); try { normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`; } catch { throw new Error('OpenCode auth JSON is not valid JSON.'); } await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, normalized, { mode: 0o600 }); }; export const createOpenCodeAdapter: AdapterFactory = (deps) => { const execStream = deps?.execStream ?? streamExecInContainer; const prepareAuth = async (workspace: AdapterWorkspace) => { // API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY // in the environment; only `opencode_auth_json` profiles need auth.json on disk. const profile = workspace.claim.aiProviderProfile; if (profile?.authType !== 'opencode_auth_json') return; if (!profile.secret) { throw new Error('OpenCode auth profile is missing auth.json contents.'); } const authPath = path.join( workspace.workdir, '.local', 'share', 'opencode', 'auth.json', ); await writeJsonFile(authPath, profile.secret); }; const runTurn = async ( workspace: AdapterWorkspace, prompt: string, onEvent: (event: NormalizedAgentEvent) => Promise, ): Promise => { workspace.runtime = 'opencode'; workspace.turnMarker = `spoon-turn-${randomUUID()}`; const marker = workspace.turnMarker; // NOTE: `--session` continuity is UNVERIFIED against `opencode run --help` and // may be dropped in Task 11's smoke; if unsupported, OpenCode turns are stateless // (acceptable — Codex/Claude carry continuity). const argv = [ 'opencode', 'run', '--format', 'json', '--model', opencodeModel(workspace.claim), ...(workspace.opencodeSessionId ? ['--session', workspace.opencodeSessionId] : []), '--', prompt, ]; const command = buildMarkedCommand(marker, argv); const aiEnv = providerEnvironment(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 normalizeOpenCodeRunLine(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: 'opencode', prepareAuth, runTurn, abort }; };