// The REAL OpenCode binary connecting to a self-host instance over MCP OAuth,// through the actual human approval screen — and recorded as ONE spliced video// (agent terminal → browser consent → agent terminal).//// Nothing about the client is modeled: OpenCode runs its own discovery against// the published metadata, its own dynamic client registration, PKCE, scope// request, and token store. The only "theater" is that the browser hop OpenCode// would normally open is captured by an open(1) shim (clients/opencode.ts) and// driven by a real Playwright browser here — which navigates the authorize URL,// lands on the forced /mcp-consent approval screen, and clicks Allow. The// consent redirect goes to OpenCode's own localhost callback, so OpenCode// receives the code and finishes the grant for real.//// The PTY (terminal.cast) and the browser (session.mp4) run concurrently and// mark focus as they act, so scripts/film.ts cuts them into one film.mp4: the// agent asks → we approve in the browser → the agent is connected.import { join } from "node:path"; import { expect } from "@effect/vitest";import { Effect } from "effect"; import { scenario } from "../src/scenario";import { enterFocus, markRecordingStart } from "../src/timeline";import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services";import { visit } from "../src/surfaces/browser"; const SERVER_NAME = "executor"; scenario( "MCP OAuth · the real OpenCode binary connects to self-host through the approval screen", { timeout: 240_000 }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const opencode = yield* OpenCode; const browser = yield* Browser; const cli = yield* Cli; const runDir = yield* RunDir; // The signed-in instance owner — the browser context gets these cookies, so // OpenCode's authorize URL lands straight on the consent screen (no login). const identity = yield* target.newIdentity(); const home = opencode.makeHome(SERVER_NAME, mcp.url); // First-run DB migration off camera so the recorded session starts clean. yield* Effect.sync(() => opencode.warmUp(home)); // Read THIS flow's authorize URL, not a stale one. const sinceIndex = home.openedUrls().length; // Wait for OpenCode to "open the browser" (the shim records the URL). const waitForAuthorizeUrl = async (): Promise<string> => { const deadline = Date.now() + 90_000; while (Date.now() < deadline) { const url = home.openedUrls()[sinceIndex]; if (url) return url.trim(); await new Promise((tick) => setTimeout(tick, 250)); } throw new Error("opencode never opened an authorization URL"); }; // The agent and the browser run concurrently: `opencode mcp auth` blocks on // its localhost callback, which only fires once the browser completes the // consent redirect. Effect.all interrupts the other side if either fails. yield* Effect.all( [ // ── Terminal: drive the real OpenCode binary ───────────────────────── cli.session( ["bash", "--norc"], async (term) => { markRecordingStart(runDir, "terminal"); await enterFocus(runDir, "terminal"); await term.screen.waitForText("$", { timeoutMs: 10_000 }); const outputAfter = (text: string, line: string): string | null => { const echoed = text.lastIndexOf(line); if (echoed === -1) return null; const after = text.slice(echoed + line.length); return after.trimEnd().endsWith("\n$") ? after : null; }; const sh = async (line: string, timeoutMs: number) => { await term.keyboard.type(line); await term.keyboard.press("Enter"); const snapshot = await term.screen.waitUntil( (current) => outputAfter(current.text, line) !== null, { timeoutMs }, ); return outputAfter(snapshot.text, line) ?? ""; }; // Blocks until the browser fiber completes consent → OpenCode's // callback receives the code → OpenCode stores the grant. const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 120_000); // Tab back to the terminal (the framework lingers on the browser // first when filming). await enterFocus(runDir, "terminal"); expect(auth, "opencode mcp auth completes").not.toContain("failed"); const list = await sh("opencode mcp list", 60_000); expect(list, "OpenCode connects after the approved grant").toContain("connected"); // The grant is real: OpenCode persisted an access token for the server. const tokens = home.storedTokens(SERVER_NAME); expect(tokens?.accessToken, "OpenCode stored an access token").toBeTruthy(); }, { cwd: home.projectDir, env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" }, record: join(runDir, "terminal.cast"), viewport: { cols: 100, rows: 40 }, }, ), // ── Browser: approve the connection on the real consent screen ─────── browser.session(identity, async ({ page, step }) => { const authorizeUrl = await waitForAuthorizeUrl(); const appHost = new URL(target.baseUrl).host; // Two named steps: each is a thing the developer pauses to look at, so // the framework's per-step beat holds the consent screen, then the // result — no hand-rolled timeouts. (The first step's enterFocus also // lingers on the terminal mid-OAuth before tabbing here.) await step("OpenCode asks to connect — the approval screen", async () => { // Authenticated (owner cookies) → authorize forces prompt=consent → // the approval screen. await visit(page, authorizeUrl); await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 }); expect(new URL(page.url()).pathname, "lands on the approval screen").toBe( "/mcp-consent", ); }); await step("Approve — back to OpenCode with a code", async () => { await page.locator("#mcp-consent-allow").click(); // Approval redirects out of the app to OpenCode's own localhost // callback (a different host) — that delivery unblocks the PTY. await page.waitForURL((url) => url.host !== appHost, { timeout: 30_000 }); }); }), ], { concurrency: "unbounded" }, ); }),);
// The REAL OpenCode binary connecting to a self-host instance over MCP OAuth,
// through the actual human approval screen — and recorded as ONE spliced video
// (agent terminal → browser consent → agent terminal).
//
// Nothing about the client is modeled: OpenCode runs its own discovery against
// the published metadata, its own dynamic client registration, PKCE, scope
// request, and token store. The only "theater" is that the browser hop OpenCode
// would normally open is captured by an open(1) shim (clients/opencode.ts) and
// driven by a real Playwright browser here — which navigates the authorize URL,
// lands on the forced /mcp-consent approval screen, and clicks Allow. The
// consent redirect goes to OpenCode's own localhost callback, so OpenCode
// receives the code and finishes the grant for real.
//
// The PTY (terminal.cast) and the browser (session.mp4) run concurrently and
// mark focus as they act, so scripts/film.ts cuts them into one film.mp4: the
// agent asks → we approve in the browser → the agent is connected.
import { join } from "node:path";
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { scenario } from "../src/scenario";
import { enterFocus, markRecordingStart } from "../src/timeline";
import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services";
import { visit } from "../src/surfaces/browser";
const SERVER_NAME = "executor";
scenario(
"MCP OAuth · the real OpenCode binary connects to self-host through the approval screen",
{ timeout: 240_000 },
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const opencode = yield* OpenCode;
const browser = yield* Browser;
const cli = yield* Cli;
const runDir = yield* RunDir;
// The signed-in instance owner — the browser context gets these cookies, so
// OpenCode's authorize URL lands straight on the consent screen (no login).
const identity = yield* target.newIdentity();
const home = opencode.makeHome(SERVER_NAME, mcp.url);
// First-run DB migration off camera so the recorded session starts clean.
yield* Effect.sync(() => opencode.warmUp(home));
// Read THIS flow's authorize URL, not a stale one.
const sinceIndex = home.openedUrls().length;
// Wait for OpenCode to "open the browser" (the shim records the URL).
const waitForAuthorizeUrl = async (): Promise<string> => {
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const url = home.openedUrls()[sinceIndex];
if (url) return url.trim();
await new Promise((tick) => setTimeout(tick, 250));
}
throw new Error("opencode never opened an authorization URL");
};
// The agent and the browser run concurrently: `opencode mcp auth` blocks on
// its localhost callback, which only fires once the browser completes the
// consent redirect. Effect.all interrupts the other side if either fails.
yield* Effect.all(
[
// ── Terminal: drive the real OpenCode binary ─────────────────────────
cli.session(
["bash", "--norc"],
async (term) => {
markRecordingStart(runDir, "terminal");
await enterFocus(runDir, "terminal");
await term.screen.waitForText("$", { timeoutMs: 10_000 });
const outputAfter = (text: string, line: string): string | null => {
const echoed = text.lastIndexOf(line);
if (echoed === -1) return null;
const after = text.slice(echoed + line.length);
return after.trimEnd().endsWith("\n$") ? after : null;
};
const sh = async (line: string, timeoutMs: number) => {
await term.keyboard.type(line);
await term.keyboard.press("Enter");
const snapshot = await term.screen.waitUntil(
(current) => outputAfter(current.text, line) !== null,
{ timeoutMs },
);
return outputAfter(snapshot.text, line) ?? "";
};
// Blocks until the browser fiber completes consent → OpenCode's
// callback receives the code → OpenCode stores the grant.
const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 120_000);
// Tab back to the terminal (the framework lingers on the browser
// first when filming).
await enterFocus(runDir, "terminal");
expect(auth, "opencode mcp auth completes").not.toContain("failed");
const list = await sh("opencode mcp list", 60_000);
expect(list, "OpenCode connects after the approved grant").toContain("connected");
// The grant is real: OpenCode persisted an access token for the server.
const tokens = home.storedTokens(SERVER_NAME);
expect(tokens?.accessToken, "OpenCode stored an access token").toBeTruthy();
},
{
cwd: home.projectDir,
env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
record: join(runDir, "terminal.cast"),
viewport: { cols: 100, rows: 40 },
},
),
// ── Browser: approve the connection on the real consent screen ───────
browser.session(identity, async ({ page, step }) => {
const authorizeUrl = await waitForAuthorizeUrl();
const appHost = new URL(target.baseUrl).host;
// Two named steps: each is a thing the developer pauses to look at, so
// the framework's per-step beat holds the consent screen, then the
// result — no hand-rolled timeouts. (The first step's enterFocus also
// lingers on the terminal mid-OAuth before tabbing here.)
await step("OpenCode asks to connect — the approval screen", async () => {
// Authenticated (owner cookies) → authorize forces prompt=consent →
// the approval screen.
await visit(page, authorizeUrl);
await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 });
expect(new URL(page.url()).pathname, "lands on the approval screen").toBe(
"/mcp-consent",
);
});
await step("Approve — back to OpenCode with a code", async () => {
await page.locator("#mcp-consent-allow").click();
// Approval redirects out of the app to OpenCode's own localhost
// callback (a different host) — that delivery unblocks the PTY.
await page.waitForURL((url) => url.host !== appHost, { timeout: 30_000 });
});
}),
],
{ concurrency: "unbounded" },
);
}),
);