History

Reproduce the OpenCode daily re-auth with the real client, on camera (#945)

* Reproduce the OpenCode daily re-auth: spec-faithful clients get no refresh token Field report: OpenCode users must re-authenticate the executor MCP every day, while Claude Code sessions persist. e2e/cloud/mcp-oauth-lifecycle.test.ts reproduces it as two scenarios that differ in exactly one step — how the client picks scopes: - spec-faithful (OpenCode, Poke, mcporter): requests exactly what our protected-resource metadata advertises in scopes_supported - hardcoding (Claude Code, Codex): ignores metadata, always requests openid profile email offline_access Each walks the full real lifecycle: discovery, DCR, PKCE authorize, token grant, working tool call, REAL access-token expiry (compressed to seconds via a per-client TTL the authorization server honors), 401, then refresh recovery. The hardcoding client survives; the spec-faithful client is left holding no refresh token and fails — because our metadata advertises scopes_supported: [], so it requested nothing and AuthKit only issues refresh tokens when offline_access is granted. The failing scenario is intentional: it is the executable form of the bug report, kept red until the metadata fix lands and flips it into a regression lock. Framework pieces (e2e MCP surface): advertisedScopes(), mintTokens() with scope + TTL control and rotating refresh(), and named client scope policies. Emulator (vendor/emulate pin): AuthKit-faithful scope grants, offline_access-gated refresh tokens, single-use rotation, and a DCR access_token_ttl_seconds extension. * Reproduce the OpenCode daily re-auth with the real opencode binary The wire-tier repro models OpenCode's scope policy; this scenario removes the model. e2e/cloud/mcp-opencode-real.test.ts drives the actual installed opencode in a hermetic home (own XDG dirs, opencode.json pointing at the target's /mcp, an open(1) shim that captures the OAuth URL instead of opening a browser): opencode mcp auth runs OpenCode's own discovery, DCR, scope selection, and token storage against our published metadata, with a fetch playing the signed-in human via login_hint. While the token is fresh, opencode mcp list reports connected; the emulator-honored 15-second TTL then really expires it, and the scenario asserts the experience a user deserves — still signed in. Red today: OpenCode's own store holds NO refresh token (it requested the empty scope list we advertise; observed on the wire: its authorize request carries no scope parameter at all), so it reports needs-authentication — the field bug, compressed from a day into seconds. Verified the scopes_supported one-liner flips it green end to end, including OpenCode's silent refresh; the fix stays out until the repro is reviewed. Pieces: an opencode capability (present when the binary is installed — skips elsewhere), e2e/src/clients/opencode.ts (hermetic real-client driver), and the emulator pin with a seedable default access-token TTL for plain-DCR clients. * Record the real-OpenCode repro as a terminal cast the viewer replays The scenario now runs its whole story in ONE recorded PTY (terminal-control) instead of piped child processes: a bash session where opencode mcp auth, the fresh connected check, the on-camera sleep past token expiry, and the needs-authentication ending are typed and rendered like a user at a shell. Assertions read the live screen (waitForText + sentinel echoes, quoted so the typed command can't satisfy them). The CLI surface's record option now writes a standard asciicast v2: terminal-control's JSONL recording is converted (streaming UTF-8 decode across output events) in the release step, so even a timed-out run keeps its evidence. The viewer's media tab generalizes video|terminal — terminal.cast plays via asciinema-player (lazy-loaded, idle stretches compressed), giving PTY scenarios the same watch-the-run property browser scenarios get from session video. * Clean up the OpenCode repro recording The cast now reads like a person at a shell: wait for the prompt to paint before typing (early keystrokes echoed above the prompt), silence macOS's zsh-deprecation banner, run OpenCode's one-time database migration off camera before the session, and replace the quoted echo sentinels with an invisible completion signal — a command is done when the bare prompt is back as the bottom line of the screen. * Natural scrollback in the OpenCode repro recording Drop the clear-between-commands: assertions now scope themselves to the text a command produced (slice after its echoed line, prompt-after-it as the completion signal), so earlier output can't satisfy later checks and the cast reads like one uninterrupted shell session. Taller viewport keeps every line on screen since the slice anchor must not scroll away. The warm-up runs opencode in a bare no-MCP project: an mcp list that probes the real server before mcp auth makes the auth flow die with Unexpected status: needs_auth. * Make the MCP OAuth lifecycle scenarios shared, not cloud-specific An MCP client surviving token expiry is a product guarantee for every target with mcp-oauth, so both lifecycle scenarios (wire-tier and the real OpenCode binary) move from e2e/cloud to e2e/scenarios. The one genuinely target-specific piece — how a test compresses the access-token lifetime — becomes a Target seam: a ttl-control capability plus setAccessTokenTtl(). Cloud implements it against its emulated authorization server; selfhost (Better Auth as the AS, no test-adjustable TTL yet) skips with the missing capability named in the matrix, which is the honest state until someone wires a knob there. The opencode binary capability is detected on both targets. * Drop the modeled scope-policy MCP client; the real client is the repro

Rhys Sullivan committed 5 months ago

Commit 4a4277c1 parent 397610013 files · +450 −88Browse files

Unified · Split

13 files
  1. bun.lock +13 −0
  2. e2e/package.json +1 −0
  3. e2e/scenarios/mcp-opencode-real.test.ts +111 −0
  4. e2e/src/clients/opencode.ts +110 −0
  5. e2e/src/surfaces/cli.ts +42 −2
  6. e2e/src/surfaces/mcp.ts +83 −70
  7. e2e/src/target.ts +9 −1
  8. e2e/targets/cloud.ts +22 −2
  9. e2e/targets/selfhost.ts +11 −3
  10. e2e/viewer/src/App.tsx +17 −9
  11. e2e/viewer/src/TerminalCast.tsx +23 −0
  12. e2e/viewer/src/styles.css +7 −0
  13. vendor/emulate +1 −1
bun.lock+13−0
@@ -328,6 +328,7 @@ · 327 unmodified lines
328328 "@executor-js/sdk": "workspace:*",
329329 "@kitlangton/terminal-control": "^0.3.0",
330330 "@modelcontextprotocol/sdk": "^1.29.0",
331+ "asciinema-player": "^3.15.1",
331332 "effect": "catalog:",
332333 "monaco-editor": "^0.55.1",
333334 "playwright": "^1.60.0",
@@ -2433,6 +2434,12 @@ · 2099 unmodified lines
24332434
24342435 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
24352436
2437+ "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
2438+
2439+ "@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="],
2440+
2441+ "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
2442+
24362443 "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
24372444
24382445 "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="],
@@ -2793,6 +2800,8 @@ · 354 unmodified lines
27932800
27942801 "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
27952802
2803+ "asciinema-player": ["asciinema-player@3.15.1", "", { "dependencies": { "@babel/runtime": "^7.21.0", "solid-js": "^1.3.0", "solid-transition-group": "^0.2.3" } }, "sha512-agVYeNlPxthLyAb92l9AS7ypW0uhesqOuQzyR58Q4Sj+MvesQztZBgx86lHqNJkB8rQ6EP0LeA9czGytQUBpYw=="],
2804+
27962805 "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
27972806
27982807 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -4657,6 +4666,10 @@ · 1858 unmodified lines
46574666
46584667 "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
46594668
4669+ "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="],
4670+
4671+ "solid-transition-group": ["solid-transition-group@0.2.3", "", { "dependencies": { "@solid-primitives/refs": "^1.0.5", "@solid-primitives/transition-group": "^1.0.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg=="],
4672+
46604673 "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
46614674
46624675 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
e2e/package.json+1−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
2121 "@executor-js/sdk": "workspace:*",
2222 "@kitlangton/terminal-control": "^0.3.0",
2323 "@modelcontextprotocol/sdk": "^1.29.0",
24+ "asciinema-player": "^3.15.1",
2425 "effect": "catalog:",
2526 "monaco-editor": "^0.55.1",
2627 "playwright": "^1.60.0",
e2e/scenarios/mcp-opencode-real.test.ts+111−0
@@ -0,0 +1,111 @@
1+// The OpenCode daily re-auth, reproduced with the REAL opencode
2+// binary in a REAL terminal. The whole session runs in one recorded PTY —
3+// the run's terminal.cast replays exactly what a user at a shell would see:
4+// authenticate, connected, wait out the token, suddenly "needs
5+// authentication" again.
6+//
7+// Nothing about the client is modeled: OpenCode runs its own discovery
8+// against our published metadata, its own DCR, its own scope selection, its
9+// own token storage. The only theater is the browser hop (an open(1) shim
10+// captures the URL and a fetch with login_hint plays the signed-in human)
11+// and time (the target's ttl-control compresses "a day" into seconds). The
12+// scenario asserts the experience a user deserves —
13+// authenticate once, stay signed in across an access-token expiry. It stays
14+// red until the server gives spec-faithful clients a way to refresh.
15+import { join } from "node:path";
16+
17+import { expect } from "@effect/vitest";
18+import { Effect } from "effect";
19+
20+import { scenario } from "../src/scenario";
21+import { completeOAuthConsent, makeOpenCodeHome, warmUp } from "../src/clients/opencode";
22+
23+const SERVER_NAME = "executor";
24+const TTL_SECONDS = 15;
25+
26+scenario(
27+ "MCP OAuth lifecycle · the real OpenCode binary stays signed in across token expiry",
28+ { needs: ["mcp-oauth", "opencode", "ttl-control"], timeout: 180_000 },
29+ (ctx) =>
30+ Effect.gen(function* () {
31+ const setTtl = ctx.target.setAccessTokenTtl;
32+ if (!setTtl)
33+ return yield* Effect.die(new Error("ttl-control target lacks setAccessTokenTtl"));
34+ const identity = yield* ctx.target.newIdentity();
35+ const email = identity.credentials?.email ?? identity.label;
36+ const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);
37+ // First-run database migration happens off camera.
38+ yield* Effect.sync(() => warmUp(home));
39+
40+ yield* setTtl(TTL_SECONDS);
41+ yield* ctx.cli
42+ .session(
43+ ["bash", "--norc"],
44+ async (term) => {
45+ // Don't type into a shell that hasn't painted its prompt yet —
46+ // early keystrokes echo above the prompt in the recording.
47+ await term.screen.waitForText("$", { timeoutMs: 10_000 });
48+
49+ // A command is done when its echoed line is on screen AND the
50+ // bare prompt is back after it — no sentinel noise, no clears,
51+ // and the pre-command prompt can't satisfy the wait. Returns
52+ // only what THIS command produced, so earlier output can't
53+ // satisfy a later assertion and the scrollback stays natural.
54+ const outputAfter = (text: string, line: string): string | null => {
55+ const echoed = text.lastIndexOf(line);
56+ if (echoed === -1) return null;
57+ const after = text.slice(echoed + line.length);
58+ return after.trimEnd().endsWith("\n$") ? after : null;
59+ };
60+ const sh = async (line: string, timeoutMs: number) => {
61+ await term.keyboard.type(line);
62+ await term.keyboard.press("Enter");
63+ const snapshot = await term.screen.waitUntil(
64+ (current) => outputAfter(current.text, line) !== null,
65+ { timeoutMs },
66+ );
67+ return outputAfter(snapshot.text, line) ?? "";
68+ };
69+
70+ // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE,
71+ // its own scope request, its own token store.
72+ const consent = completeOAuthConsent(home, email, home.openedUrls().length);
73+ const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 60_000);
74+ await consent;
75+ expect(auth, "opencode mcp auth completes").not.toContain("failed");
76+
77+ // While the token is fresh, OpenCode is a working MCP client.
78+ const fresh = await sh("opencode mcp list", 60_000);
79+ expect(fresh, "OpenCode connects on a fresh token").toContain("connected");
80+
81+ // The access token genuinely expires on camera (server-honored
82+ // TTL, no fakes), then the same command runs again.
83+ const expired = await sh(
84+ `sleep ${TTL_SECONDS + 3}; opencode mcp list`,
85+ (TTL_SECONDS + 3) * 1000 + 60_000,
86+ );
87+
88+ // The experience a user deserves: still signed in. OpenCode
89+ // requested exactly the scopes our metadata advertises; whether
90+ // it got a refresh token decides this assertion — that's the bug.
91+ const tokens = home.storedTokens(SERVER_NAME);
92+ expect(
93+ expired,
94+ `OpenCode stays signed in across token expiry (its store holds ${
95+ tokens?.refreshToken ? "a refresh token" : "NO refresh token"
96+ })`,
97+ ).toContain("connected");
98+ },
99+ {
100+ cwd: home.projectDir,
101+ env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
102+ record: join(ctx.dir, "terminal.cast"),
103+ // Tall enough that the whole session stays on screen — the
104+ // per-command slice in sh() depends on the echoed line not
105+ // scrolling away.
106+ viewport: { cols: 100, rows: 40 },
107+ },
108+ )
109+ .pipe(Effect.ensuring(setTtl(null)));
110+ }),
111+);
e2e/src/clients/opencode.ts+110−0
@@ -0,0 +1,110 @@
1+// Drive the REAL installed OpenCode binary as an MCP client, hermetically:
2+// its own XDG dirs, a project dir whose opencode.json points at the target's
3+// /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file
4+// we can read instead of a window. What OpenCode does with discovery, scopes,
5+// tokens, and refresh is entirely its own code — that is the point.
6+import { spawnSync } from "node:child_process";
7+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
8+import { tmpdir } from "node:os";
9+import { join } from "node:path";
10+
11+/** Whether the real OpenCode binary is installed — the "opencode" capability. */
12+export const hasOpenCode = (): boolean => spawnSync("opencode", ["--version"]).status === 0;
13+
14+export interface OpenCodeHome {
15+ /** Working directory holding opencode.json (OpenCode reads config from cwd). */
16+ readonly projectDir: string;
17+ /** Environment that isolates this OpenCode from the machine's real one. */
18+ readonly env: Record<string, string>;
19+ /** Every URL OpenCode tried to open in a browser, in order. */
20+ readonly openedUrls: () => ReadonlyArray<string>;
21+ /** OpenCode's own MCP token store (undefined until it persists a grant). */
22+ readonly storedTokens: (
23+ serverName: string,
24+ ) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined;
25+}
26+
27+/** A throwaway OpenCode installation configured with one remote MCP server. */
28+export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHome => {
29+ const root = mkdtempSync(join(tmpdir(), "e2e-opencode-"));
30+ const projectDir = join(root, "project");
31+ const dataDir = join(root, "data");
32+ const binDir = join(root, "bin");
33+ const openedUrlsFile = join(root, "opened-urls.txt");
34+ for (const dir of [projectDir, dataDir, binDir]) mkdirSync(dir, { recursive: true });
35+
36+ writeFileSync(
37+ join(projectDir, "opencode.json"),
38+ JSON.stringify({
39+ $schema: "https://opencode.ai/config.json",
40+ mcp: { [serverName]: { type: "remote", url: mcpUrl } },
41+ }),
42+ );
43+ // OpenCode launches the OAuth URL via `open`; the shim records it instead.
44+ writeFileSync(join(binDir, "open"), `#!/bin/sh\necho "$@" >> ${openedUrlsFile}\nexit 0\n`, {
45+ mode: 0o755,
46+ });
47+
48+ return {
49+ projectDir,
50+ env: {
51+ ...process.env,
52+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
53+ XDG_DATA_HOME: dataDir,
54+ XDG_CONFIG_HOME: join(root, "config"),
55+ XDG_STATE_HOME: join(root, "state"),
56+ XDG_CACHE_HOME: join(root, "cache"),
57+ },
58+ openedUrls: () =>
59+ existsSync(openedUrlsFile)
60+ ? readFileSync(openedUrlsFile, "utf8").split("\n").filter(Boolean)
61+ : [],
62+ storedTokens: (name) => {
63+ const file = join(dataDir, "opencode", "mcp-auth.json");
64+ if (!existsSync(file)) return undefined;
65+ const store = JSON.parse(readFileSync(file, "utf8")) as Record<
66+ string,
67+ { tokens?: { accessToken?: string; refreshToken?: string; expiresAt?: number } }
68+ >;
69+ return store[name]?.tokens;
70+ },
71+ };
72+};
73+
74+/**
75+ * Run OpenCode's one-time first-run work (database migration) off camera so
76+ * a recorded session starts clean. Runs in a bare project with NO MCP
77+ * servers configured: `mcp auth` errors with "Unexpected status: needs_auth"
78+ * if an earlier `mcp list` already probed the server, so the warm-up must
79+ * never touch it.
80+ */
81+export const warmUp = (home: OpenCodeHome): void => {
82+ const bare = join(home.projectDir, "..", "warmup");
83+ mkdirSync(bare, { recursive: true });
84+ writeFileSync(join(bare, "opencode.json"), "{}");
85+ spawnSync("opencode", ["mcp", "list"], { cwd: bare, env: home.env, timeout: 60_000 });
86+};
87+
88+/**
89+ * Play the signed-in human for an OAuth flow OpenCode just started: wait for
90+ * it to "open the browser" (the shim records the URL instead), then follow
91+ * the authorize URL with login_hint — the emulator's consent redirects the
92+ * code straight to OpenCode's localhost callback.
93+ */
94+export const completeOAuthConsent = async (
95+ home: OpenCodeHome,
96+ email: string,
97+ sinceIndex: number,
98+): Promise<void> => {
99+ const deadline = Date.now() + 30_000;
100+ while (Date.now() < deadline) {
101+ const url = home.openedUrls()[sinceIndex];
102+ if (url) {
103+ const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);
104+ if (!response.ok) throw new Error(`consent redirect chain failed (${response.status})`);
105+ return;
106+ }
107+ await new Promise((tick) => setTimeout(tick, 250));
108+ }
109+ throw new Error("opencode never opened an authorization URL");
110+};
e2e/src/surfaces/cli.ts+42−2
@@ -1,6 +1,9 @@
11 // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the
22 // session (type/press/waitForText) and asserts on the rendered screen with
3−// vitest; pass `record` to capture an asciinema-style cast file if wanted.
3+// vitest; pass `record` to save an asciicast v2 the viewer can replay. The
4+// recording is written in release so a timeout still leaves the evidence.
5+import { writeFileSync } from "node:fs";
6+
47 import { Effect } from "effect";
58 import { TerminalControl, type Session } from "@kitlangton/terminal-control";
69
@@ -11,11 +14,43 @@ export interface CliSurface { · 4 unmodified lines
1114 options?: {
1215 readonly cwd?: string;
1316 readonly env?: Record<string, string>;
17+ /** Path to write an asciicast v2 (.cast) of the whole session. */
1418 readonly record?: string;
19+ readonly viewport?: { readonly cols: number; readonly rows: number };
1520 },
1621 ) => Effect.Effect<T>;
1722 }
1823
24+/** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */
25+const toAsciicast = (recording: Uint8Array): string => {
26+ const events = new TextDecoder()
27+ .decode(recording)
28+ .split("\n")
29+ .filter(Boolean)
30+ .map(
31+ (line) =>
32+ JSON.parse(line) as {
33+ type: string;
34+ cols?: number;
35+ rows?: number;
36+ at_ms?: number;
37+ bytes?: number[];
38+ },
39+ );
40+ const header = events.find((event) => event.type === "header");
41+ const lines = [
42+ JSON.stringify({ version: 2, width: header?.cols ?? 80, height: header?.rows ?? 24 }),
43+ ];
44+ // One streaming decoder so multi-byte UTF-8 split across events survives.
45+ const decoder = new TextDecoder();
46+ for (const event of events) {
47+ if (event.type !== "output") continue;
48+ const text = decoder.decode(Uint8Array.from(event.bytes ?? []), { stream: true });
49+ if (text) lines.push(JSON.stringify([(event.at_ms ?? 0) / 1000, "o", text]));
50+ }
51+ return `${lines.join("\n")}\n`;
52+};
53+
1954 // acquireUseRelease so a vitest timeout (fiber interruption) still tears the
2055 // PTY down instead of leaking the child process.
2156 export const makeCliSurface = (): CliSurface => ({
@@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({ · 5 unmodified lines
2762 command,
2863 cwd: options?.cwd,
2964 env: options?.env,
30− record: options?.record,
65+ record: options?.record ? true : undefined,
66+ viewport: options?.viewport,
3167 });
3268 return { tc, session };
3369 }),
3470 ({ session }) => Effect.promise(() => drive(session)),
3571 ({ tc, session }) =>
3672 Effect.promise(async () => {
73+ if (options?.record) {
74+ const recording = await session.recording().catch(() => undefined);
75+ if (recording) writeFileSync(options.record, toAsciicast(recording));
76+ }
3777 await session.stop().catch(() => {});
3878 await tc[Symbol.asyncDispose]();
3979 }),
e2e/src/surfaces/mcp.ts+83−70
@@ -32,11 +32,12 @@ export interface McpSession { · 31 unmodified lines
3232 export interface McpSurface {
3333 readonly session: (identity: Identity) => McpSession;
3434 /**
35− * Mint a real MCP bearer exactly the way an MCP client does, headlessly:
36− * protected-resource discovery → authorization-server discovery → dynamic
37− * client registration → authorize with PKCE (consent via the target's
38− * strategy) → code exchange. For raw-wire scenarios that drive /mcp without
39− * an MCP client library.
35+ * Mint a real MCP bearer headlessly: protected-resource discovery →
36+ * authorization-server discovery → dynamic client registration → authorize
37+ * with PKCE (consent via the target's strategy) → code exchange. Plumbing
38+ * for raw-wire scenarios that drive /mcp without an MCP client library —
39+ * client *behavior* (scope choices, refresh, token storage) is never
40+ * modeled here; that's what driving the real client binaries is for.
4041 */
4142 readonly mintBearer: (email: string) => Effect.Effect<string>;
4243 }
@@ -52,70 +53,78 @@ const textOf = (result: unknown): string => { · 9 unmodified lines
5253 return typeof result === "string" ? result : JSON.stringify(result);
5354 };
5455
56+interface TokenResponse {
57+ readonly access_token?: string;
58+}
59+
60+const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
61+ const consent = target.mcpConsent?.({
62+ label: email,
63+ credentials: { email, password: "" },
64+ });
65+ if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
66+
67+ const mcpPath = new URL(target.mcpUrl).pathname;
68+ const resource = (await (
69+ await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
70+ ).json()) as { authorization_servers?: ReadonlyArray<string> };
71+ const issuer = resource.authorization_servers?.[0];
72+ if (!issuer) throw new Error("mintBearer: no authorization server advertised");
73+ const metadata = (await (
74+ await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
75+ ).json()) as {
76+ readonly authorization_endpoint: string;
77+ readonly token_endpoint: string;
78+ readonly registration_endpoint: string;
79+ };
80+
81+ const redirectUri = "http://127.0.0.1:9/callback";
82+ const registered = (await (
83+ await fetch(metadata.registration_endpoint, {
84+ method: "POST",
85+ headers: { "content-type": "application/json" },
86+ body: JSON.stringify({
87+ client_name: "executor-e2e",
88+ redirect_uris: [redirectUri],
89+ grant_types: ["authorization_code", "refresh_token"],
90+ response_types: ["code"],
91+ token_endpoint_auth_method: "none",
92+ }),
93+ })
94+ ).json()) as { readonly client_id: string };
95+
96+ const verifier = randomBytes(32).toString("base64url");
97+ const authorizeUrl = new URL(metadata.authorization_endpoint);
98+ authorizeUrl.searchParams.set("client_id", registered.client_id);
99+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
100+ authorizeUrl.searchParams.set("response_type", "code");
101+ authorizeUrl.searchParams.set("state", randomUUID());
102+ authorizeUrl.searchParams.set(
103+ "code_challenge",
104+ createHash("sha256").update(verifier).digest("base64url"),
105+ );
106+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
107+ const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
108+
109+ const token = (await (
110+ await fetch(metadata.token_endpoint, {
111+ method: "POST",
112+ headers: { "content-type": "application/x-www-form-urlencoded" },
113+ body: new URLSearchParams({
114+ grant_type: "authorization_code",
115+ code,
116+ redirect_uri: redirectUri,
117+ client_id: registered.client_id,
118+ code_verifier: verifier,
119+ }),
120+ })
121+ ).json()) as TokenResponse;
122+ if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
123+ return token.access_token;
124+};
125+
55126 export const makeMcpSurface = (target: Target): McpSurface => ({
56− mintBearer: (email) =>
57− Effect.promise(async () => {
58− const consent = target.mcpConsent?.({ label: email, credentials: { email, password: "" } });
59− if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
60−
61− const mcpPath = new URL(target.mcpUrl).pathname;
62− const resource = (await (
63− await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
64− ).json()) as { readonly authorization_servers?: ReadonlyArray<string> };
65− const issuer = resource.authorization_servers?.[0];
66− if (!issuer) throw new Error("mintBearer: no authorization server advertised");
67− const metadata = (await (
68− await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
69− ).json()) as {
70− readonly authorization_endpoint: string;
71− readonly token_endpoint: string;
72− readonly registration_endpoint: string;
73− };
74−
75− const redirectUri = "http://127.0.0.1:9/callback";
76− const registered = (await (
77− await fetch(metadata.registration_endpoint, {
78− method: "POST",
79− headers: { "content-type": "application/json" },
80− body: JSON.stringify({
81− client_name: "executor-e2e",
82− redirect_uris: [redirectUri],
83− grant_types: ["authorization_code"],
84− response_types: ["code"],
85− token_endpoint_auth_method: "none",
86− }),
87− })
88− ).json()) as { readonly client_id: string };
89−
90− const verifier = randomBytes(32).toString("base64url");
91− const authorizeUrl = new URL(metadata.authorization_endpoint);
92− authorizeUrl.searchParams.set("client_id", registered.client_id);
93− authorizeUrl.searchParams.set("redirect_uri", redirectUri);
94− authorizeUrl.searchParams.set("response_type", "code");
95− authorizeUrl.searchParams.set("state", randomUUID());
96− authorizeUrl.searchParams.set(
97− "code_challenge",
98− createHash("sha256").update(verifier).digest("base64url"),
99− );
100− authorizeUrl.searchParams.set("code_challenge_method", "S256");
101− const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
102−
103− const token = (await (
104− await fetch(metadata.token_endpoint, {
105− method: "POST",
106− headers: { "content-type": "application/x-www-form-urlencoded" },
107− body: new URLSearchParams({
108− grant_type: "authorization_code",
109− code,
110− redirect_uri: redirectUri,
111− client_id: registered.client_id,
112− code_verifier: verifier,
113− }),
114− })
115− ).json()) as { readonly access_token?: string };
116− if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
117− return token.access_token;
118− }),
127+ mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
119128 session: (identity) => {
120129 const serverName = target.name;
121130 let runtimePromise: Promise<Runtime> | undefined;
@@ -132,9 +141,13 @@ export const makeMcpSurface = (target: Target): McpSurface => ({ · 10 unmodified lines
132141 const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-"));
133142 writeFileSync(
134143 join(dir, "mcporter.json"),
135− JSON.stringify({ mcpServers: { [serverName]: { url: target.mcpUrl } } }),
144+ JSON.stringify({
145+ mcpServers: { [serverName]: { url: target.mcpUrl } },
146+ }),
136147 );
137− runtimePromise = createRuntime({ configPath: join(dir, "mcporter.json") });
148+ runtimePromise = createRuntime({
149+ configPath: join(dir, "mcporter.json"),
150+ });
138151 }
139152 return runtimePromise;
140153 };
e2e/src/target.ts+9−1
@@ -11,7 +11,9 @@ export type Capability = · 10 unmodified lines
1111 | "browser" // web UI reachable + identity injectable into a browser context
1212 | "mcp-oauth" // MCP endpoint with a headless OAuth consent path
1313 | "cli" // a CLI/TUI entry point exists for this target
14− | "billing"; // billing limits are enforced (cloud-only)
14+ | "billing" // billing limits are enforced (cloud-only)
15+ | "opencode" // the real OpenCode binary is installed on this host
16+ | "ttl-control"; // the authorization server's access-token TTL is test-adjustable
1517
1618 export interface Identity {
1719 /** Shown in transcripts ("user_ab12cd") */
@@ -40,4 +42,10 @@ export interface Target { · 22 unmodified lines
4042 readonly mcpConsent?: (
4143 identity: Identity,
4244 ) => (request: { authorizationUrl: string }) => Promise<{ code: string }>;
45+ /**
46+ * Compress (or restore, with null) the authorization server's access-token
47+ * lifetime, when "ttl-control" is supported — what lets token-expiry
48+ * scenarios cross a REAL expiry in seconds instead of an hour.
49+ */
50+ readonly setAccessTokenTtl?: (seconds: number | null) => Effect.Effect<void>;
4351 }
e2e/targets/cloud.ts+22−2
@@ -8,7 +8,8 @@ import { randomUUID } from "node:crypto"; · 7 unmodified lines
88
99 import { Effect } from "effect";
1010
11−import type { Identity, Target } from "../src/target";
11+import type { Capability, Identity, Target } from "../src/target";
12+import { hasOpenCode } from "../src/clients/opencode";
1213
1314 export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798);
1415 export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436);
@@ -54,7 +55,26 @@ export const cloudTarget = (): Target => ({ · 39 unmodified lines
5455 name: "cloud",
5556 baseUrl: CLOUD_BASE_URL,
5657 mcpUrl: `${CLOUD_BASE_URL}/mcp`,
57− capabilities: new Set(["api", "browser", "billing", "mcp-oauth"]),
58+ capabilities: new Set<Capability>([
59+ "api",
60+ "browser",
61+ "billing",
62+ "mcp-oauth",
63+ // Cloud's authorization server is the WorkOS emulator, so token-expiry
64+ // scenarios can compress the lifecycle.
65+ "ttl-control",
66+ // Real-client capability is environmental, not a property of the deployment.
67+ ...(hasOpenCode() ? (["opencode"] as const) : []),
68+ ]),
69+ setAccessTokenTtl: (seconds) =>
70+ Effect.promise(async () => {
71+ const response = await fetch(`http://127.0.0.1:${WORKOS_EMULATOR_PORT}/_emulate/seed`, {
72+ method: "POST",
73+ headers: { "content-type": "application/json" },
74+ body: JSON.stringify({ oauth: { default_access_token_ttl_seconds: seconds } }),
75+ });
76+ if (!response.ok) throw new Error(`seeding emulator TTL failed (${response.status})`);
77+ }),
5878 newIdentity: ({ org = true } = {}) =>
5979 Effect.promise(async (): Promise<Identity> => {
6080 const label = `user-${randomUUID().slice(0, 8)}`;
e2e/targets/selfhost.ts+11−3
@@ -6,7 +6,8 @@ import { Effect } from "effect"; · 5 unmodified lines
66
77 import { cookieConsentStrategy } from "@executor-js/mcporter";
88
9−import type { Identity, Target } from "../src/target";
9+import type { Capability, Identity, Target } from "../src/target";
10+import { hasOpenCode } from "../src/clients/opencode";
1011
1112 export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799);
1213 export const SELFHOST_BASE_URL =
@@ -48,10 +49,17 @@ export const selfhostTarget = (): Target => ({ · 35 unmodified lines
4849 name: "selfhost",
4950 baseUrl: SELFHOST_BASE_URL,
5051 mcpUrl: `${SELFHOST_BASE_URL}/mcp`,
51− // No "billing" (no limits). Identity is the bootstrap admin for now —
52+ // No "billing" (no limits) and no "ttl-control" yet (Better Auth is the
53+ // authorization server; its token TTL isn't test-adjustable, so token-expiry
54+ // scenarios skip here). Identity is the bootstrap admin for now —
5255 // single-tenant; per-test invite-signup isolation is the next step here, so
5356 // browser scenarios must prefix the resources they create.
54− capabilities: new Set(["api", "browser", "mcp-oauth"]),
57+ capabilities: new Set<Capability>([
58+ "api",
59+ "browser",
60+ "mcp-oauth",
61+ ...(hasOpenCode() ? (["opencode"] as const) : []),
62+ ]),
5563 newIdentity: () =>
5664 Effect.promise(async (): Promise<Identity> => {
5765 // Sign in once and carry the session in both shapes: `headers` for the
e2e/viewer/src/App.tsx+17−9
@@ -1,6 +1,7 @@
11 import React, { Suspense, useEffect, useState } from "react";
22
33 const TestSource = React.lazy(() => import("./TestSource"));
4+const TerminalCast = React.lazy(() => import("./TerminalCast"));
45
56 // ---------------------------------------------------------------------------
67 // The matrix (scenario × target health) plus a per-run artifact page. The
@@ -140,7 +141,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 133 unmodified lines
140141 const base = `${target}/${slug}`;
141142 const [result, setResult] = useState<RunResult | null>(null);
142143 const [error, setError] = useState<string | null>(null);
143− const [tab, setTab] = useState<"video" | "source">("video");
144+ const [tab, setTab] = useState<"media" | "source">("media");
144145
145146 useEffect(() => {
146147 fetch(`${base}/result.json`)
@@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 8 unmodified lines
155156 const has = (name: string) => result.artifacts.includes(name);
156157 const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
157158 const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;
159+ const cast = has("terminal.cast") ? "terminal.cast" : null;
160+ const media = video ?? cast;
158161 const traceUrl = has("trace.zip")
159162 ? `https://trace.playwright.dev/?trace=${encodeURIComponent(
160163 new URL(`${base}/trace.zip`, window.location.href).toString(),
@@ -184,13 +187,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 23 unmodified lines
184187 {new Date(result.endedAt).toLocaleString()}
185188 </p>
186189 {result.error && <pre className="errbox">{result.error}</pre>}
187− {video && has("test.ts") && (
190+ {media && has("test.ts") && (
188191 <div className="tabs">
189192 <button
190− className={tab === "video" ? "tab active" : "tab"}
191− onClick={() => setTab("video")}
193+ className={tab === "media" ? "tab active" : "tab"}
194+ onClick={() => setTab("media")}
192195 >
193− ▶ video
196+ ▶ {video ? "video" : "terminal"}
194197 </button>
195198 <button
196199 className={tab === "source" ? "tab active" : "tab"}
@@ -200,13 +203,18 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 3 unmodified lines
200203 </button>
201204 </div>
202205 )}
203− {(!video || tab === "source") && has("test.ts") && (
206+ {(!media || tab === "source") && has("test.ts") && (
204207 <Suspense fallback={<p className="dim">loading test source…</p>}>
205− {!video && <h2 className="section">The test</h2>}
208+ {!media && <h2 className="section">The test</h2>}
206209 <TestSource url={`${base}/test.ts`} />
207210 </Suspense>
208211 )}
209− {video && tab === "video" && (
212+ {cast && !video && tab === "media" && (
213+ <Suspense fallback={<p className="dim">loading recording…</p>}>
214+ <TerminalCast url={`${base}/${cast}`} />
215+ </Suspense>
216+ )}
217+ {video && tab === "media" && (
210218 <>
211219 {/* muted is required for browsers to honor autoplay */}
212220 <video
@@ -234,7 +242,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 21 unmodified lines
234242 )}
235243 </>
236244 )}
237− {!video && !has("test.ts") && screenshots.length === 0 && (
245+ {!media && !has("test.ts") && screenshots.length === 0 && (
238246 <p className="dim">
239247 No visual artifacts — this surface's source of truth is the test code and its assertions.
240248 </p>
e2e/viewer/src/TerminalCast.tsx+23−0
@@ -0,0 +1,23 @@
1+// asciinema-player over a run's terminal.cast — the terminal twin of the
2+// browser surface's session video. Idle stretches (token-expiry waits) are
3+// compressed so playback stays watchable.
4+import { useEffect, useRef } from "react";
5+import * as AsciinemaPlayer from "asciinema-player";
6+import "asciinema-player/dist/bundle/asciinema-player.css";
7+
8+const TerminalCast = ({ url }: { url: string }) => {
9+ const mount = useRef<HTMLDivElement>(null);
10+ useEffect(() => {
11+ if (!mount.current) return;
12+ const player = AsciinemaPlayer.create(url, mount.current, {
13+ autoPlay: true,
14+ idleTimeLimit: 2,
15+ fit: "width",
16+ terminalFontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
17+ });
18+ return () => player.dispose();
19+ }, [url]);
20+ return <div ref={mount} className="cast-player" />;
21+};
22+
23+export default TerminalCast;
e2e/viewer/src/styles.css+7−0
@@ -179,3 +179,10 @@ a.watch.no { · 178 unmodified lines
179179 color: #d7dce5;
180180 border-bottom-color: #388bfd;
181181 }
182+
183+.cast-player {
184+ margin-top: 12px;
185+ border-radius: 8px;
186+ overflow: hidden;
187+ border: 1px solid #2a2a2a;
188+}
vendor/emulate+1−1
@@ -1 +1 @@
1−Subproject commit 41f09e973c174d4d64c60b9f4ec271cf7ef9c2eb
1+Subproject commit 36638da8c104e92cb3522e0dca336618cdfd8205

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t