// The OpenCode daily re-auth, reproduced with the REAL opencode // binary in a REAL terminal. The whole session runs in one recorded PTY — // the run's terminal.cast replays exactly what a user at a shell would see: // authenticate, connected, wait out the token, suddenly "needs // authentication" again. // // Nothing about the client is modeled: OpenCode runs its own discovery // against our published metadata, its own DCR, its own scope selection, its // own token storage. The only theater is the browser hop (an open(1) shim // captures the URL and a fetch with login_hint plays the signed-in human) // and time (the target's ttl-control compresses "a day" into seconds). The // scenario asserts the experience a user deserves — // authenticate once, stay signed in across an access-token expiry. It stays // red until the server gives spec-faithful clients a way to refresh. import { join } from "node:path"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Cli, Mcp, OpenCode, RunDir, Target, TtlControl } from "../src/services"; const SERVER_NAME = "executor"; const TTL_SECONDS = 15; scenario( "MCP OAuth lifecycle · the real OpenCode binary stays signed in across token expiry", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const opencode = yield* OpenCode; const setTtl = yield* TtlControl; const runDir = yield* RunDir; const cli = yield* Cli; const identity = yield* target.newIdentity(); const email = identity.credentials?.email ?? identity.label; const home = opencode.makeHome(SERVER_NAME, mcp.url); // First-run database migration happens off camera. yield* Effect.sync(() => opencode.warmUp(home)); yield* setTtl(TTL_SECONDS); yield* cli .session( ["bash", "--norc"], async (term) => { // Don't type into a shell that hasn't painted its prompt yet — // early keystrokes echo above the prompt in the recording. await term.screen.waitForText("$", { timeoutMs: 10_000 }); // A command is done when its echoed line is on screen AND the // bare prompt is back after it — no sentinel noise, no clears, // and the pre-command prompt can't satisfy the wait. Returns // only what THIS command produced, so earlier output can't // satisfy a later assertion and the scrollback stays natural. 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) ?? ""; }; // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE, // its own scope request, its own token store. const consent = opencode.completeOAuthConsent(home, email, home.openedUrls().length); const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 60_000); await consent; expect(auth, "opencode mcp auth completes").not.toContain("failed"); // While the token is fresh, OpenCode is a working MCP client. const fresh = await sh("opencode mcp list", 60_000); expect(fresh, "OpenCode connects on a fresh token").toContain("connected"); // The access token genuinely expires on camera (server-honored // TTL, no fakes), then the same command runs again. const expired = await sh( `sleep ${TTL_SECONDS + 3}; opencode mcp list`, (TTL_SECONDS + 3) * 1000 + 60_000, ); // The experience a user deserves: still signed in. OpenCode // requested exactly the scopes our metadata advertises; whether // it got a refresh token decides this assertion — that's the bug. const tokens = home.storedTokens(SERVER_NAME); expect( expired, `OpenCode stays signed in across token expiry (its store holds ${ tokens?.refreshToken ? "a refresh token" : "NO refresh token" })`, ).toContain("connected"); }, { cwd: home.projectDir, env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" }, record: join(runDir, "terminal.cast"), // Tall enough that the whole session stays on screen — the // per-command slice in sh() depends on the echoed line not // scrolling away. viewport: { cols: 100, rows: 40 }, }, ) .pipe(Effect.ensuring(setTtl(null))); }), );