// Drive the REAL installed OpenCode binary as an MCP client, hermetically: // its own XDG dirs, a project dir whose opencode.json points at the target's // /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file // we can read instead of a window. What OpenCode does with discovery, scopes, // tokens, and refresh is entirely its own code — that is the point. import { spawn, spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; /** Whether the real OpenCode binary is installed — the "opencode" capability. */ export const hasOpenCode = (): boolean => spawnSync("opencode", ["--version"]).status === 0; export interface OpenCodeHome { /** Working directory holding opencode.json (OpenCode reads config from cwd). */ readonly projectDir: string; /** Environment that isolates this OpenCode from the machine's real one. */ readonly env: Record; /** Every URL OpenCode tried to open in a browser, in order. */ readonly openedUrls: () => ReadonlyArray; /** OpenCode's own MCP token store (undefined until it persists a grant). */ readonly storedTokens: ( serverName: string, ) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined; } /** A throwaway OpenCode installation configured with one remote MCP server. * * With `chatBrainUrl` set, the config also declares a `replay` provider * pointing OpenCode's LLM traffic at a local replay brain * (clients/replay-brain.ts) and selects it as the model — real agent, * scripted conversation. Tool permissions are pre-allowed so the recorded * TUI session flows without approval dialogs. * * Pass `root` to make the home persistent/reattachable (multi-turn CLI use) * instead of a fresh temp dir; an existing root's config is left alone. */ export const makeOpenCodeHome = ( serverName: string, mcpUrl: string, options?: { readonly chatBrainUrl?: string; readonly root?: string }, ): OpenCodeHome => { const root = options?.root ?? mkdtempSync(join(tmpdir(), "e2e-opencode-")); const projectDir = join(root, "project"); const dataDir = join(root, "data"); const binDir = join(root, "bin"); const openedUrlsFile = join(root, "opened-urls.txt"); const fresh = !existsSync(projectDir); for (const dir of [projectDir, dataDir, binDir]) mkdirSync(dir, { recursive: true }); if (fresh) { writeFileSync( join(projectDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { [serverName]: { type: "remote", url: mcpUrl } }, ...(options?.chatBrainUrl ? { autoupdate: false, share: "disabled", model: "replay/replay-model", permission: { "*": "allow" }, provider: { replay: { name: "Replay", npm: "@ai-sdk/openai-compatible", options: { baseURL: options.chatBrainUrl, apiKey: "replay-key" }, models: { "replay-model": { name: "Replay Model" } }, }, }, } : {}), }), ); } // OpenCode launches the OAuth URL via `open`; the shim records it instead. writeFileSync(join(binDir, "open"), `#!/bin/sh\necho "$@" >> ${openedUrlsFile}\nexit 0\n`, { mode: 0o755, }); return { projectDir, env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}`, XDG_DATA_HOME: dataDir, XDG_CONFIG_HOME: join(root, "config"), XDG_STATE_HOME: join(root, "state"), XDG_CACHE_HOME: join(root, "cache"), }, openedUrls: () => existsSync(openedUrlsFile) ? readFileSync(openedUrlsFile, "utf8").split("\n").filter(Boolean) : [], storedTokens: (name) => { const file = join(dataDir, "opencode", "mcp-auth.json"); if (!existsSync(file)) return undefined; const store = JSON.parse(readFileSync(file, "utf8")) as Record< string, { tokens?: { accessToken?: string; refreshToken?: string; expiresAt?: number } } >; return store[name]?.tokens; }, }; }; /** * Run OpenCode's one-time first-run work (database migration) off camera so * a recorded session starts clean. Runs in a bare project with NO MCP * servers configured: `mcp auth` errors with "Unexpected status: needs_auth" * if an earlier `mcp list` already probed the server, so the warm-up must * never touch it. */ export const warmUp = (home: OpenCodeHome): void => { const bare = join(home.projectDir, "..", "warmup"); mkdirSync(bare, { recursive: true }); writeFileSync(join(bare, "opencode.json"), "{}"); spawnSync("opencode", ["mcp", "list"], { cwd: bare, env: home.env, timeout: 60_000 }); }; /** * Play the signed-in human for an OAuth flow OpenCode just started: wait for * it to "open the browser" (the shim records the URL instead), then follow * the authorize URL with login_hint — the emulator's consent redirects the * code straight to OpenCode's localhost callback. */ export const completeOAuthConsent = async ( home: OpenCodeHome, email: string, sinceIndex: number, ): Promise => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const url = home.openedUrls()[sinceIndex]; if (url) { const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`); if (!response.ok) throw new Error(`consent redirect chain failed (${response.status})`); return; } await new Promise((tick) => setTimeout(tick, 250)); } throw new Error("opencode never opened an authorization URL"); }; // --------------------------------------------------------------------------- // One-shot / multi-turn headless runs (`bun run cli opencode`) // --------------------------------------------------------------------------- const hostAuthFile = (): string => join(homedir(), ".local", "share", "opencode", "auth.json"); export const hasOpenCodeCredential = (): boolean => existsSync(hostAuthFile()); export interface OpenCodeRunOptions { readonly prompt: string; /** `provider/model`, e.g. `opencode/deepseek-v4-flash` (the default). */ readonly model?: string; /** Continue the home's most recent session (multi-turn). */ readonly continueSession?: boolean; /** Reuse a prior run's home (REQUIRED for continueSession to see it). */ readonly home?: OpenCodeHome; /** Expose one remote MCP server to the model (OpenCode's native client). * OAuth, if the server needs it, is the caller's job (mcp auth + consent * — see the mcp-opencode-real scenario). */ readonly mcp?: { readonly serverName: string; readonly url: string }; readonly timeoutMs?: number; } export interface OpenCodeRunResult { readonly answerText: string; /** Raw JSONL events (--format json), parsed, for grading/artifacts. */ readonly events: readonly Record[]; readonly home: OpenCodeHome; readonly exitCode: number | null; readonly durationMs: number; readonly stderr: string; } /** Drive the real OpenCode binary headless and hermetic: its own XDG home * seeded with the machine's subscription credential. Pass `home` + * `continueSession` for multi-turn; pass `mcp` to use OpenCode's native MCP * client against one of our targets. */ export const runOpenCode = async (options: OpenCodeRunOptions): Promise => { if (!hasOpenCodeCredential()) { throw new Error( `opencode: no credential at ${hostAuthFile()} — run \`opencode auth login\` once on this machine`, ); } const PLACEHOLDER_MCP = "http://127.0.0.1:9/"; const home = options.home ?? makeOpenCodeHome(options.mcp?.serverName ?? "none", options.mcp?.url ?? PLACEHOLDER_MCP); const configPath = join(home.projectDir, "opencode.json"); if (!options.mcp && readFileSync(configPath, "utf8").includes(PLACEHOLDER_MCP)) { // makeOpenCodeHome always writes an mcp block; a plain run wants none. writeFileSync( configPath, JSON.stringify({ $schema: "https://opencode.ai/config.json", share: "disabled" }), ); } // Seed the subscription credential into the hermetic home. const authDir = join(home.env.XDG_DATA_HOME ?? "", "opencode"); mkdirSync(authDir, { recursive: true }); copyFileSync(hostAuthFile(), join(authDir, "auth.json")); const args = [ "run", "-m", options.model ?? "opencode/deepseek-v4-flash", "--format", "json", ...(options.continueSession ? ["--continue"] : []), options.prompt, ]; const startedAt = Date.now(); const child = spawn("opencode", args, { cwd: home.projectDir, env: { ...home.env, PWD: home.projectDir }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8"))); child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString("utf8"))); const exitCode = await new Promise((resolve) => { const killer = setTimeout(() => child.kill("SIGKILL"), options.timeoutMs ?? 240_000); child.once("exit", (code) => { clearTimeout(killer); resolve(code); }); }); const events: Record[] = []; for (const line of stdout.split("\n")) { if (!line.trim()) continue; // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: tolerant parse of opencode's JSONL stream try { events.push(JSON.parse(line) as Record); } catch { // Non-JSON line — keep going. } } const answerText = events .filter( (event) => event.type === "text" && typeof (event.part as { text?: string } | undefined)?.text === "string", ) .map((event) => (event.part as { text: string }).text) .join("\n"); return { answerText, events, home, exitCode, durationMs: Date.now() - startedAt, stderr }; };