diff --git a/bun.lock b/bun.lock
index f84d8e796d..f84327e22f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -328,6 +328,7 @@
"@executor-js/sdk": "workspace:*",
"@kitlangton/terminal-control": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
+ "asciinema-player": "^3.15.1",
"effect": "catalog:",
"monaco-editor": "^0.55.1",
"playwright": "^1.60.0",
@@ -2433,6 +2434,12 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
+ "@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=="],
+
+ "@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="],
+
+ "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
+
"@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
"@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="],
@@ -2793,6 +2800,8 @@
"array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
+ "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=="],
+
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -4657,6 +4666,10 @@
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
+ "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="],
+
+ "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=="],
+
"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=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
diff --git a/e2e/package.json b/e2e/package.json
index 78fecce2e5..12a434736d 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -21,6 +21,7 @@
"@executor-js/sdk": "workspace:*",
"@kitlangton/terminal-control": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
+ "asciinema-player": "^3.15.1",
"effect": "catalog:",
"monaco-editor": "^0.55.1",
"playwright": "^1.60.0",
diff --git a/e2e/scenarios/mcp-opencode-real.test.ts b/e2e/scenarios/mcp-opencode-real.test.ts
new file mode 100644
index 0000000000..e33fa35ba4
--- /dev/null
+++ b/e2e/scenarios/mcp-opencode-real.test.ts
@@ -0,0 +1,111 @@
+// 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 { completeOAuthConsent, makeOpenCodeHome, warmUp } from "../src/clients/opencode";
+
+const SERVER_NAME = "executor";
+const TTL_SECONDS = 15;
+
+scenario(
+ "MCP OAuth lifecycle · the real OpenCode binary stays signed in across token expiry",
+ { needs: ["mcp-oauth", "opencode", "ttl-control"], timeout: 180_000 },
+ (ctx) =>
+ Effect.gen(function* () {
+ const setTtl = ctx.target.setAccessTokenTtl;
+ if (!setTtl)
+ return yield* Effect.die(new Error("ttl-control target lacks setAccessTokenTtl"));
+ const identity = yield* ctx.target.newIdentity();
+ const email = identity.credentials?.email ?? identity.label;
+ const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);
+ // First-run database migration happens off camera.
+ yield* Effect.sync(() => warmUp(home));
+
+ yield* setTtl(TTL_SECONDS);
+ yield* ctx.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 = 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(ctx.dir, "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)));
+ }),
+);
diff --git a/e2e/src/clients/opencode.ts b/e2e/src/clients/opencode.ts
new file mode 100644
index 0000000000..6d3c9ead3a
--- /dev/null
+++ b/e2e/src/clients/opencode.ts
@@ -0,0 +1,110 @@
+// 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;
+ /** Every URL OpenCode tried to open in a browser, in order. */
+ readonly openedUrls: () => ReadonlyArray;
+ /** 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. */
+export const makeOpenCodeHome = (serverName: string, mcpUrl: 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 } },
+ }),
+ );
+ // 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 => {
+ 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");
+};
diff --git a/e2e/src/surfaces/cli.ts b/e2e/src/surfaces/cli.ts
index 050f6d6eeb..908c3b4fd7 100644
--- a/e2e/src/surfaces/cli.ts
+++ b/e2e/src/surfaces/cli.ts
@@ -1,6 +1,9 @@
// CLI/TUI surface: a real PTY via terminal-control. The scenario drives the
// session (type/press/waitForText) and asserts on the rendered screen with
-// vitest; pass `record` to capture an asciinema-style cast file if wanted.
+// vitest; pass `record` to save an asciicast v2 the viewer can replay. The
+// recording is written in release so a timeout still leaves the evidence.
+import { writeFileSync } from "node:fs";
+
import { Effect } from "effect";
import { TerminalControl, type Session } from "@kitlangton/terminal-control";
@@ -11,11 +14,43 @@ export interface CliSurface {
options?: {
readonly cwd?: string;
readonly env?: Record;
+ /** Path to write an asciicast v2 (.cast) of the whole session. */
readonly record?: string;
+ readonly viewport?: { readonly cols: number; readonly rows: number };
},
) => Effect.Effect;
}
+/** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */
+const toAsciicast = (recording: Uint8Array): string => {
+ const events = new TextDecoder()
+ .decode(recording)
+ .split("\n")
+ .filter(Boolean)
+ .map(
+ (line) =>
+ JSON.parse(line) as {
+ type: string;
+ cols?: number;
+ rows?: number;
+ at_ms?: number;
+ bytes?: number[];
+ },
+ );
+ const header = events.find((event) => event.type === "header");
+ const lines = [
+ JSON.stringify({ version: 2, width: header?.cols ?? 80, height: header?.rows ?? 24 }),
+ ];
+ // One streaming decoder so multi-byte UTF-8 split across events survives.
+ const decoder = new TextDecoder();
+ for (const event of events) {
+ if (event.type !== "output") continue;
+ const text = decoder.decode(Uint8Array.from(event.bytes ?? []), { stream: true });
+ if (text) lines.push(JSON.stringify([(event.at_ms ?? 0) / 1000, "o", text]));
+ }
+ return `${lines.join("\n")}\n`;
+};
+
// acquireUseRelease so a vitest timeout (fiber interruption) still tears the
// PTY down instead of leaking the child process.
export const makeCliSurface = (): CliSurface => ({
@@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({
command,
cwd: options?.cwd,
env: options?.env,
- record: options?.record,
+ record: options?.record ? true : undefined,
+ viewport: options?.viewport,
});
return { tc, session };
}),
({ session }) => Effect.promise(() => drive(session)),
({ tc, session }) =>
Effect.promise(async () => {
+ if (options?.record) {
+ const recording = await session.recording().catch(() => undefined);
+ if (recording) writeFileSync(options.record, toAsciicast(recording));
+ }
await session.stop().catch(() => {});
await tc[Symbol.asyncDispose]();
}),
diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts
index 9b3f4805b2..a2632878d5 100644
--- a/e2e/src/surfaces/mcp.ts
+++ b/e2e/src/surfaces/mcp.ts
@@ -32,11 +32,12 @@ export interface McpSession {
export interface McpSurface {
readonly session: (identity: Identity) => McpSession;
/**
- * Mint a real MCP bearer exactly the way an MCP client does, headlessly:
- * protected-resource discovery → authorization-server discovery → dynamic
- * client registration → authorize with PKCE (consent via the target's
- * strategy) → code exchange. For raw-wire scenarios that drive /mcp without
- * an MCP client library.
+ * Mint a real MCP bearer headlessly: protected-resource discovery →
+ * authorization-server discovery → dynamic client registration → authorize
+ * with PKCE (consent via the target's strategy) → code exchange. Plumbing
+ * for raw-wire scenarios that drive /mcp without an MCP client library —
+ * client *behavior* (scope choices, refresh, token storage) is never
+ * modeled here; that's what driving the real client binaries is for.
*/
readonly mintBearer: (email: string) => Effect.Effect;
}
@@ -52,70 +53,78 @@ const textOf = (result: unknown): string => {
return typeof result === "string" ? result : JSON.stringify(result);
};
+interface TokenResponse {
+ readonly access_token?: string;
+}
+
+const mintBearerFlow = async (target: Target, email: string): Promise => {
+ const consent = target.mcpConsent?.({
+ label: email,
+ credentials: { email, password: "" },
+ });
+ if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
+
+ const mcpPath = new URL(target.mcpUrl).pathname;
+ const resource = (await (
+ await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
+ ).json()) as { authorization_servers?: ReadonlyArray };
+ const issuer = resource.authorization_servers?.[0];
+ if (!issuer) throw new Error("mintBearer: no authorization server advertised");
+ const metadata = (await (
+ await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
+ ).json()) as {
+ readonly authorization_endpoint: string;
+ readonly token_endpoint: string;
+ readonly registration_endpoint: string;
+ };
+
+ const redirectUri = "http://127.0.0.1:9/callback";
+ const registered = (await (
+ await fetch(metadata.registration_endpoint, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ client_name: "executor-e2e",
+ redirect_uris: [redirectUri],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ }),
+ })
+ ).json()) as { readonly client_id: string };
+
+ const verifier = randomBytes(32).toString("base64url");
+ const authorizeUrl = new URL(metadata.authorization_endpoint);
+ authorizeUrl.searchParams.set("client_id", registered.client_id);
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
+ authorizeUrl.searchParams.set("response_type", "code");
+ authorizeUrl.searchParams.set("state", randomUUID());
+ authorizeUrl.searchParams.set(
+ "code_challenge",
+ createHash("sha256").update(verifier).digest("base64url"),
+ );
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
+ const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
+
+ const token = (await (
+ await fetch(metadata.token_endpoint, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: registered.client_id,
+ code_verifier: verifier,
+ }),
+ })
+ ).json()) as TokenResponse;
+ if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
+ return token.access_token;
+};
+
export const makeMcpSurface = (target: Target): McpSurface => ({
- mintBearer: (email) =>
- Effect.promise(async () => {
- const consent = target.mcpConsent?.({ label: email, credentials: { email, password: "" } });
- if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
-
- const mcpPath = new URL(target.mcpUrl).pathname;
- const resource = (await (
- await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
- ).json()) as { readonly authorization_servers?: ReadonlyArray };
- const issuer = resource.authorization_servers?.[0];
- if (!issuer) throw new Error("mintBearer: no authorization server advertised");
- const metadata = (await (
- await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
- ).json()) as {
- readonly authorization_endpoint: string;
- readonly token_endpoint: string;
- readonly registration_endpoint: string;
- };
-
- const redirectUri = "http://127.0.0.1:9/callback";
- const registered = (await (
- await fetch(metadata.registration_endpoint, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- client_name: "executor-e2e",
- redirect_uris: [redirectUri],
- grant_types: ["authorization_code"],
- response_types: ["code"],
- token_endpoint_auth_method: "none",
- }),
- })
- ).json()) as { readonly client_id: string };
-
- const verifier = randomBytes(32).toString("base64url");
- const authorizeUrl = new URL(metadata.authorization_endpoint);
- authorizeUrl.searchParams.set("client_id", registered.client_id);
- authorizeUrl.searchParams.set("redirect_uri", redirectUri);
- authorizeUrl.searchParams.set("response_type", "code");
- authorizeUrl.searchParams.set("state", randomUUID());
- authorizeUrl.searchParams.set(
- "code_challenge",
- createHash("sha256").update(verifier).digest("base64url"),
- );
- authorizeUrl.searchParams.set("code_challenge_method", "S256");
- const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
-
- const token = (await (
- await fetch(metadata.token_endpoint, {
- method: "POST",
- headers: { "content-type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "authorization_code",
- code,
- redirect_uri: redirectUri,
- client_id: registered.client_id,
- code_verifier: verifier,
- }),
- })
- ).json()) as { readonly access_token?: string };
- if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
- return token.access_token;
- }),
+ mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
session: (identity) => {
const serverName = target.name;
let runtimePromise: Promise | undefined;
@@ -132,9 +141,13 @@ export const makeMcpSurface = (target: Target): McpSurface => ({
const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-"));
writeFileSync(
join(dir, "mcporter.json"),
- JSON.stringify({ mcpServers: { [serverName]: { url: target.mcpUrl } } }),
+ JSON.stringify({
+ mcpServers: { [serverName]: { url: target.mcpUrl } },
+ }),
);
- runtimePromise = createRuntime({ configPath: join(dir, "mcporter.json") });
+ runtimePromise = createRuntime({
+ configPath: join(dir, "mcporter.json"),
+ });
}
return runtimePromise;
};
diff --git a/e2e/src/target.ts b/e2e/src/target.ts
index 32e1138ec3..32697580b5 100644
--- a/e2e/src/target.ts
+++ b/e2e/src/target.ts
@@ -11,7 +11,9 @@ export type Capability =
| "browser" // web UI reachable + identity injectable into a browser context
| "mcp-oauth" // MCP endpoint with a headless OAuth consent path
| "cli" // a CLI/TUI entry point exists for this target
- | "billing"; // billing limits are enforced (cloud-only)
+ | "billing" // billing limits are enforced (cloud-only)
+ | "opencode" // the real OpenCode binary is installed on this host
+ | "ttl-control"; // the authorization server's access-token TTL is test-adjustable
export interface Identity {
/** Shown in transcripts ("user_ab12cd") */
@@ -40,4 +42,10 @@ export interface Target {
readonly mcpConsent?: (
identity: Identity,
) => (request: { authorizationUrl: string }) => Promise<{ code: string }>;
+ /**
+ * Compress (or restore, with null) the authorization server's access-token
+ * lifetime, when "ttl-control" is supported — what lets token-expiry
+ * scenarios cross a REAL expiry in seconds instead of an hour.
+ */
+ readonly setAccessTokenTtl?: (seconds: number | null) => Effect.Effect;
}
diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts
index cc6b8e268b..a512aa4d2e 100644
--- a/e2e/targets/cloud.ts
+++ b/e2e/targets/cloud.ts
@@ -8,7 +8,8 @@ import { randomUUID } from "node:crypto";
import { Effect } from "effect";
-import type { Identity, Target } from "../src/target";
+import type { Capability, Identity, Target } from "../src/target";
+import { hasOpenCode } from "../src/clients/opencode";
export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798);
export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436);
@@ -54,7 +55,26 @@ export const cloudTarget = (): Target => ({
name: "cloud",
baseUrl: CLOUD_BASE_URL,
mcpUrl: `${CLOUD_BASE_URL}/mcp`,
- capabilities: new Set(["api", "browser", "billing", "mcp-oauth"]),
+ capabilities: new Set([
+ "api",
+ "browser",
+ "billing",
+ "mcp-oauth",
+ // Cloud's authorization server is the WorkOS emulator, so token-expiry
+ // scenarios can compress the lifecycle.
+ "ttl-control",
+ // Real-client capability is environmental, not a property of the deployment.
+ ...(hasOpenCode() ? (["opencode"] as const) : []),
+ ]),
+ setAccessTokenTtl: (seconds) =>
+ Effect.promise(async () => {
+ const response = await fetch(`http://127.0.0.1:${WORKOS_EMULATOR_PORT}/_emulate/seed`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ oauth: { default_access_token_ttl_seconds: seconds } }),
+ });
+ if (!response.ok) throw new Error(`seeding emulator TTL failed (${response.status})`);
+ }),
newIdentity: ({ org = true } = {}) =>
Effect.promise(async (): Promise => {
const label = `user-${randomUUID().slice(0, 8)}`;
diff --git a/e2e/targets/selfhost.ts b/e2e/targets/selfhost.ts
index 1892af7f6a..1b5ae3ed01 100644
--- a/e2e/targets/selfhost.ts
+++ b/e2e/targets/selfhost.ts
@@ -6,7 +6,8 @@ import { Effect } from "effect";
import { cookieConsentStrategy } from "@executor-js/mcporter";
-import type { Identity, Target } from "../src/target";
+import type { Capability, Identity, Target } from "../src/target";
+import { hasOpenCode } from "../src/clients/opencode";
export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799);
export const SELFHOST_BASE_URL =
@@ -48,10 +49,17 @@ export const selfhostTarget = (): Target => ({
name: "selfhost",
baseUrl: SELFHOST_BASE_URL,
mcpUrl: `${SELFHOST_BASE_URL}/mcp`,
- // No "billing" (no limits). Identity is the bootstrap admin for now —
+ // No "billing" (no limits) and no "ttl-control" yet (Better Auth is the
+ // authorization server; its token TTL isn't test-adjustable, so token-expiry
+ // scenarios skip here). Identity is the bootstrap admin for now —
// single-tenant; per-test invite-signup isolation is the next step here, so
// browser scenarios must prefix the resources they create.
- capabilities: new Set(["api", "browser", "mcp-oauth"]),
+ capabilities: new Set([
+ "api",
+ "browser",
+ "mcp-oauth",
+ ...(hasOpenCode() ? (["opencode"] as const) : []),
+ ]),
newIdentity: () =>
Effect.promise(async (): Promise => {
// Sign in once and carry the session in both shapes: `headers` for the
diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx
index 8eed5e8d54..c36272626a 100644
--- a/e2e/viewer/src/App.tsx
+++ b/e2e/viewer/src/App.tsx
@@ -1,6 +1,7 @@
import React, { Suspense, useEffect, useState } from "react";
const TestSource = React.lazy(() => import("./TestSource"));
+const TerminalCast = React.lazy(() => import("./TerminalCast"));
// ---------------------------------------------------------------------------
// The matrix (scenario × target health) plus a per-run artifact page. The
@@ -140,7 +141,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
const base = `${target}/${slug}`;
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
- const [tab, setTab] = useState<"video" | "source">("video");
+ const [tab, setTab] = useState<"media" | "source">("media");
useEffect(() => {
fetch(`${base}/result.json`)
@@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
const has = (name: string) => result.artifacts.includes(name);
const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;
+ const cast = has("terminal.cast") ? "terminal.cast" : null;
+ const media = video ?? cast;
const traceUrl = has("trace.zip")
? `https://trace.playwright.dev/?trace=${encodeURIComponent(
new URL(`${base}/trace.zip`, window.location.href).toString(),
@@ -184,13 +187,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
{new Date(result.endedAt).toLocaleString()}
{result.error && {result.error}}
- {video && has("test.ts") && (
+ {media && has("test.ts") && (
)}
- {(!video || tab === "source") && has("test.ts") && (
+ {(!media || tab === "source") && has("test.ts") && (
loading test source…}>
- {!video && The test
}
+ {!media && The test
}
)}
- {video && tab === "video" && (
+ {cast && !video && tab === "media" && (
+ loading recording…}>
+
+
+ )}
+ {video && tab === "media" && (
<>
{/* muted is required for browsers to honor autoplay */}