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 */}