// 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<string, string>; /** Every URL OpenCode tried to open in a browser, in order. */ readonly openedUrls: () => ReadonlyArray<string>; /** 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<void> => { 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<string, unknown>[]; 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<OpenCodeRunResult> => { 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<number | null>((resolve) => { const killer = setTimeout(() => child.kill("SIGKILL"), options.timeoutMs ?? 240_000); child.once("exit", (code) => { clearTimeout(killer); resolve(code); }); }); const events: Record<string, unknown>[] = []; 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<string, unknown>); } 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 };};
// 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<string, string>;
/** Every URL OpenCode tried to open in a browser, in order. */
readonly openedUrls: () => ReadonlyArray<string>;
/** 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<void> => {
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<string, unknown>[];
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<OpenCodeRunResult> => {
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<number | null>((resolve) => {
const killer = setTimeout(() => child.kill("SIGKILL"), options.timeoutMs ?? 240_000);
child.once("exit", (code) => {
clearTimeout(killer);
resolve(code);
});
});
const events: Record<string, unknown>[] = [];
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<string, unknown>);
} 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 };
};