// 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 { spawnSync } from "node:child_process";import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";import { 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. */export const makeOpenCodeHome = ( serverName: string, mcpUrl: string, options?: { readonly chatBrainUrl?: string },): OpenCodeHome => { const 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"); for (const dir of [projectDir, dataDir, binDir]) mkdirSync(dir, { recursive: true }); 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 the platform browser opener; the shim // records it instead. macOS uses `open`; Linux uses `xdg-open` (and a few // fallback names), so shim them all to the same recorder — otherwise the URL // is never captured on Linux and consent never completes. for (const opener of ["open", "xdg-open", "www-browser", "x-www-browser"]) { writeFileSync(join(binDir, opener), `#!/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");};
// 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 { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { 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. */
export const makeOpenCodeHome = (
serverName: string,
mcpUrl: string,
options?: { readonly chatBrainUrl?: string },
): OpenCodeHome => {
const 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");
for (const dir of [projectDir, dataDir, binDir]) mkdirSync(dir, { recursive: true });
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 the platform browser opener; the shim
// records it instead. macOS uses `open`; Linux uses `xdg-open` (and a few
// fallback names), so shim them all to the same recorder — otherwise the URL
// is never captured on Linux and consent never completes.
for (const opener of ["open", "xdg-open", "www-browser", "x-www-browser"]) {
writeFileSync(join(binDir, opener), `#!/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");
};