* 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
Unified · Split
| @@ -328,6 +328,7 @@ · 327 unmodified lines | |||
| 328 | "@executor-js/sdk": "workspace:*", | 328 | "@executor-js/sdk": "workspace:*", |
| 329 | "@kitlangton/terminal-control": "^0.3.0", | 329 | "@kitlangton/terminal-control": "^0.3.0", |
| 330 | "@modelcontextprotocol/sdk": "^1.29.0", | 330 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 331 | "asciinema-player": "^3.15.1", | ||
| 331 | "effect": "catalog:", | 332 | "effect": "catalog:", |
| 332 | "monaco-editor": "^0.55.1", | 333 | "monaco-editor": "^0.55.1", |
| 333 | "playwright": "^1.60.0", | 334 | "playwright": "^1.60.0", |
| @@ -2433,6 +2434,12 @@ · 2099 unmodified lines | |||
| 2433 | 2434 | ||
| 2434 | "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], | 2435 | "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], |
| 2435 | 2436 | ||
| 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 | |||
| 2436 | "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], | 2443 | "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], |
| 2437 | 2444 | ||
| 2438 | "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], | 2445 | "@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 | |||
| 2793 | 2800 | ||
| 2794 | "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], | 2801 | "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], |
| 2795 | 2802 | ||
| 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 | |||
| 2796 | "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], | 2805 | "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], |
| 2797 | 2806 | ||
| 2798 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | 2807 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| @@ -4657,6 +4666,10 @@ · 1858 unmodified lines | |||
| 4657 | 4666 | ||
| 4658 | "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], | 4667 | "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], |
| 4659 | 4668 | ||
| 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 | |||
| 4660 | "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=="], | 4673 | "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=="], |
| 4661 | 4674 | ||
| 4662 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], | 4675 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], |
| @@ -21,6 +21,7 @@ · 20 unmodified lines | |||
| 21 | "@executor-js/sdk": "workspace:*", | 21 | "@executor-js/sdk": "workspace:*", |
| 22 | "@kitlangton/terminal-control": "^0.3.0", | 22 | "@kitlangton/terminal-control": "^0.3.0", |
| 23 | "@modelcontextprotocol/sdk": "^1.29.0", | 23 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 24 | "asciinema-player": "^3.15.1", | ||
| 24 | "effect": "catalog:", | 25 | "effect": "catalog:", |
| 25 | "monaco-editor": "^0.55.1", | 26 | "monaco-editor": "^0.55.1", |
| 26 | "playwright": "^1.60.0", | 27 | "playwright": "^1.60.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 | ); | ||
| @@ -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 | }; | ||
| @@ -1,6 +1,9 @@ | |||
| 1 | // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the | 1 | // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the |
| 2 | // session (type/press/waitForText) and asserts on the rendered screen with | 2 | // 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 | |||
| 4 | import { Effect } from "effect"; | 7 | import { Effect } from "effect"; |
| 5 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; | 8 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; |
| 6 | 9 | ||
| @@ -11,11 +14,43 @@ export interface CliSurface { · 4 unmodified lines | |||
| 11 | options?: { | 14 | options?: { |
| 12 | readonly cwd?: string; | 15 | readonly cwd?: string; |
| 13 | readonly env?: Record<string, string>; | 16 | readonly env?: Record<string, string>; |
| 17 | /** Path to write an asciicast v2 (.cast) of the whole session. */ | ||
| 14 | readonly record?: string; | 18 | readonly record?: string; |
| 19 | readonly viewport?: { readonly cols: number; readonly rows: number }; | ||
| 15 | }, | 20 | }, |
| 16 | ) => Effect.Effect<T>; | 21 | ) => Effect.Effect<T>; |
| 17 | } | 22 | } |
| 18 | 23 | ||
| 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 | |||
| 19 | // acquireUseRelease so a vitest timeout (fiber interruption) still tears the | 54 | // acquireUseRelease so a vitest timeout (fiber interruption) still tears the |
| 20 | // PTY down instead of leaking the child process. | 55 | // PTY down instead of leaking the child process. |
| 21 | export const makeCliSurface = (): CliSurface => ({ | 56 | export const makeCliSurface = (): CliSurface => ({ |
| @@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({ · 5 unmodified lines | |||
| 27 | command, | 62 | command, |
| 28 | cwd: options?.cwd, | 63 | cwd: options?.cwd, |
| 29 | env: options?.env, | 64 | env: options?.env, |
| 30 | record: options?.record, | 65 | record: options?.record ? true : undefined, |
| 66 | viewport: options?.viewport, | ||
| 31 | }); | 67 | }); |
| 32 | return { tc, session }; | 68 | return { tc, session }; |
| 33 | }), | 69 | }), |
| 34 | ({ session }) => Effect.promise(() => drive(session)), | 70 | ({ session }) => Effect.promise(() => drive(session)), |
| 35 | ({ tc, session }) => | 71 | ({ tc, session }) => |
| 36 | Effect.promise(async () => { | 72 | Effect.promise(async () => { |
| 73 | if (options?.record) { | ||
| 74 | const recording = await session.recording().catch(() => undefined); | ||
| 75 | if (recording) writeFileSync(options.record, toAsciicast(recording)); | ||
| 76 | } | ||
| 37 | await session.stop().catch(() => {}); | 77 | await session.stop().catch(() => {}); |
| 38 | await tc[Symbol.asyncDispose](); | 78 | await tc[Symbol.asyncDispose](); |
| 39 | }), | 79 | }), |
| @@ -32,11 +32,12 @@ export interface McpSession { · 31 unmodified lines | |||
| 32 | export interface McpSurface { | 32 | export interface McpSurface { |
| 33 | readonly session: (identity: Identity) => McpSession; | 33 | readonly session: (identity: Identity) => McpSession; |
| 34 | /** | 34 | /** |
| 35 | * Mint a real MCP bearer exactly the way an MCP client does, headlessly: | 35 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| 36 | * protected-resource discovery → authorization-server discovery → dynamic | 36 | * authorization-server discovery → dynamic client registration → authorize |
| 37 | * client registration → authorize with PKCE (consent via the target's | 37 | * with PKCE (consent via the target's strategy) → code exchange. Plumbing |
| 38 | * strategy) → code exchange. For raw-wire scenarios that drive /mcp without | 38 | * for raw-wire scenarios that drive /mcp without an MCP client library — |
| 39 | * 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. | ||
| 40 | */ | 41 | */ |
| 41 | readonly mintBearer: (email: string) => Effect.Effect<string>; | 42 | readonly mintBearer: (email: string) => Effect.Effect<string>; |
| 42 | } | 43 | } |
| @@ -52,70 +53,78 @@ const textOf = (result: unknown): string => { · 9 unmodified lines | |||
| 52 | return typeof result === "string" ? result : JSON.stringify(result); | 53 | return typeof result === "string" ? result : JSON.stringify(result); |
| 53 | }; | 54 | }; |
| 54 | 55 | ||
| 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 | |||
| 55 | export const makeMcpSurface = (target: Target): McpSurface => ({ | 126 | export const makeMcpSurface = (target: Target): McpSurface => ({ |
| 56 | mintBearer: (email) => | 127 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, 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 | }), | ||
| 119 | session: (identity) => { | 128 | session: (identity) => { |
| 120 | const serverName = target.name; | 129 | const serverName = target.name; |
| 121 | let runtimePromise: Promise<Runtime> | undefined; | 130 | let runtimePromise: Promise<Runtime> | undefined; |
| @@ -132,9 +141,13 @@ export const makeMcpSurface = (target: Target): McpSurface => ({ · 10 unmodified lines | |||
| 132 | const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-")); | 141 | const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-")); |
| 133 | writeFileSync( | 142 | writeFileSync( |
| 134 | join(dir, "mcporter.json"), | 143 | join(dir, "mcporter.json"), |
| 135 | JSON.stringify({ mcpServers: { [serverName]: { url: target.mcpUrl } } }), | 144 | JSON.stringify({ |
| 145 | mcpServers: { [serverName]: { url: target.mcpUrl } }, | ||
| 146 | }), | ||
| 136 | ); | 147 | ); |
| 137 | runtimePromise = createRuntime({ configPath: join(dir, "mcporter.json") }); | 148 | runtimePromise = createRuntime({ |
| 149 | configPath: join(dir, "mcporter.json"), | ||
| 150 | }); | ||
| 138 | } | 151 | } |
| 139 | return runtimePromise; | 152 | return runtimePromise; |
| 140 | }; | 153 | }; |
| @@ -11,7 +11,9 @@ export type Capability = · 10 unmodified lines | |||
| 11 | | "browser" // web UI reachable + identity injectable into a browser context | 11 | | "browser" // web UI reachable + identity injectable into a browser context |
| 12 | | "mcp-oauth" // MCP endpoint with a headless OAuth consent path | 12 | | "mcp-oauth" // MCP endpoint with a headless OAuth consent path |
| 13 | | "cli" // a CLI/TUI entry point exists for this target | 13 | | "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 | ||
| 15 | 17 | ||
| 16 | export interface Identity { | 18 | export interface Identity { |
| 17 | /** Shown in transcripts ("user_ab12cd") */ | 19 | /** Shown in transcripts ("user_ab12cd") */ |
| @@ -40,4 +42,10 @@ export interface Target { · 22 unmodified lines | |||
| 40 | readonly mcpConsent?: ( | 42 | readonly mcpConsent?: ( |
| 41 | identity: Identity, | 43 | identity: Identity, |
| 42 | ) => (request: { authorizationUrl: string }) => Promise<{ code: string }>; | 44 | ) => (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>; | ||
| 43 | } | 51 | } |
| @@ -8,7 +8,8 @@ import { randomUUID } from "node:crypto"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | import { Effect } from "effect"; | 9 | import { Effect } from "effect"; |
| 10 | 10 | ||
| 11 | import type { Identity, Target } from "../src/target"; | 11 | import type { Capability, Identity, Target } from "../src/target"; |
| 12 | import { hasOpenCode } from "../src/clients/opencode"; | ||
| 12 | 13 | ||
| 13 | export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798); | 14 | export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798); |
| 14 | export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436); | 15 | export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436); |
| @@ -54,7 +55,26 @@ export const cloudTarget = (): Target => ({ · 39 unmodified lines | |||
| 54 | name: "cloud", | 55 | name: "cloud", |
| 55 | baseUrl: CLOUD_BASE_URL, | 56 | baseUrl: CLOUD_BASE_URL, |
| 56 | mcpUrl: `${CLOUD_BASE_URL}/mcp`, | 57 | 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 | }), | ||
| 58 | newIdentity: ({ org = true } = {}) => | 78 | newIdentity: ({ org = true } = {}) => |
| 59 | Effect.promise(async (): Promise<Identity> => { | 79 | Effect.promise(async (): Promise<Identity> => { |
| 60 | const label = `user-${randomUUID().slice(0, 8)}`; | 80 | const label = `user-${randomUUID().slice(0, 8)}`; |
| @@ -6,7 +6,8 @@ import { Effect } from "effect"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { cookieConsentStrategy } from "@executor-js/mcporter"; | 7 | import { cookieConsentStrategy } from "@executor-js/mcporter"; |
| 8 | 8 | ||
| 9 | import type { Identity, Target } from "../src/target"; | 9 | import type { Capability, Identity, Target } from "../src/target"; |
| 10 | import { hasOpenCode } from "../src/clients/opencode"; | ||
| 10 | 11 | ||
| 11 | export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799); | 12 | export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799); |
| 12 | export const SELFHOST_BASE_URL = | 13 | export const SELFHOST_BASE_URL = |
| @@ -48,10 +49,17 @@ export const selfhostTarget = (): Target => ({ · 35 unmodified lines | |||
| 48 | name: "selfhost", | 49 | name: "selfhost", |
| 49 | baseUrl: SELFHOST_BASE_URL, | 50 | baseUrl: SELFHOST_BASE_URL, |
| 50 | mcpUrl: `${SELFHOST_BASE_URL}/mcp`, | 51 | 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 — | ||
| 52 | // single-tenant; per-test invite-signup isolation is the next step here, so | 55 | // single-tenant; per-test invite-signup isolation is the next step here, so |
| 53 | // browser scenarios must prefix the resources they create. | 56 | // 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 | ]), | ||
| 55 | newIdentity: () => | 63 | newIdentity: () => |
| 56 | Effect.promise(async (): Promise<Identity> => { | 64 | Effect.promise(async (): Promise<Identity> => { |
| 57 | // Sign in once and carry the session in both shapes: `headers` for the | 65 | // Sign in once and carry the session in both shapes: `headers` for the |
| @@ -1,6 +1,7 @@ | |||
| 1 | import React, { Suspense, useEffect, useState } from "react"; | 1 | import React, { Suspense, useEffect, useState } from "react"; |
| 2 | 2 | ||
| 3 | const TestSource = React.lazy(() => import("./TestSource")); | 3 | const TestSource = React.lazy(() => import("./TestSource")); |
| 4 | const TerminalCast = React.lazy(() => import("./TerminalCast")); | ||
| 4 | 5 | ||
| 5 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 6 | // The matrix (scenario × target health) plus a per-run artifact page. The | 7 | // 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 | |||
| 140 | const base = `${target}/${slug}`; | 141 | const base = `${target}/${slug}`; |
| 141 | const [result, setResult] = useState<RunResult | null>(null); | 142 | const [result, setResult] = useState<RunResult | null>(null); |
| 142 | const [error, setError] = useState<string | null>(null); | 143 | const [error, setError] = useState<string | null>(null); |
| 143 | const [tab, setTab] = useState<"video" | "source">("video"); | 144 | const [tab, setTab] = useState<"media" | "source">("media"); |
| 144 | 145 | ||
| 145 | useEffect(() => { | 146 | useEffect(() => { |
| 146 | fetch(`${base}/result.json`) | 147 | fetch(`${base}/result.json`) |
| @@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 8 unmodified lines | |||
| 155 | const has = (name: string) => result.artifacts.includes(name); | 156 | const has = (name: string) => result.artifacts.includes(name); |
| 156 | const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); | 157 | const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); |
| 157 | const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null; | 158 | 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; | ||
| 158 | const traceUrl = has("trace.zip") | 161 | const traceUrl = has("trace.zip") |
| 159 | ? `https://trace.playwright.dev/?trace=${encodeURIComponent( | 162 | ? `https://trace.playwright.dev/?trace=${encodeURIComponent( |
| 160 | new URL(`${base}/trace.zip`, window.location.href).toString(), | 163 | new URL(`${base}/trace.zip`, window.location.href).toString(), |
| @@ -184,13 +187,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 23 unmodified lines | |||
| 184 | {new Date(result.endedAt).toLocaleString()} | 187 | {new Date(result.endedAt).toLocaleString()} |
| 185 | </p> | 188 | </p> |
| 186 | {result.error && <pre className="errbox">{result.error}</pre>} | 189 | {result.error && <pre className="errbox">{result.error}</pre>} |
| 187 | {video && has("test.ts") && ( | 190 | {media && has("test.ts") && ( |
| 188 | <div className="tabs"> | 191 | <div className="tabs"> |
| 189 | <button | 192 | <button |
| 190 | className={tab === "video" ? "tab active" : "tab"} | 193 | className={tab === "media" ? "tab active" : "tab"} |
| 191 | onClick={() => setTab("video")} | 194 | onClick={() => setTab("media")} |
| 192 | > | 195 | > |
| 193 | ▶ video | 196 | ▶ {video ? "video" : "terminal"} |
| 194 | </button> | 197 | </button> |
| 195 | <button | 198 | <button |
| 196 | className={tab === "source" ? "tab active" : "tab"} | 199 | className={tab === "source" ? "tab active" : "tab"} |
| @@ -200,13 +203,18 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 3 unmodified lines | |||
| 200 | </button> | 203 | </button> |
| 201 | </div> | 204 | </div> |
| 202 | )} | 205 | )} |
| 203 | {(!video || tab === "source") && has("test.ts") && ( | 206 | {(!media || tab === "source") && has("test.ts") && ( |
| 204 | <Suspense fallback={<p className="dim">loading test source…</p>}> | 207 | <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>} |
| 206 | <TestSource url={`${base}/test.ts`} /> | 209 | <TestSource url={`${base}/test.ts`} /> |
| 207 | </Suspense> | 210 | </Suspense> |
| 208 | )} | 211 | )} |
| 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" && ( | ||
| 210 | <> | 218 | <> |
| 211 | {/* muted is required for browsers to honor autoplay */} | 219 | {/* muted is required for browsers to honor autoplay */} |
| 212 | <video | 220 | <video |
| @@ -234,7 +242,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 21 unmodified lines | |||
| 234 | )} | 242 | )} |
| 235 | </> | 243 | </> |
| 236 | )} | 244 | )} |
| 237 | {!video && !has("test.ts") && screenshots.length === 0 && ( | 245 | {!media && !has("test.ts") && screenshots.length === 0 && ( |
| 238 | <p className="dim"> | 246 | <p className="dim"> |
| 239 | No visual artifacts — this surface's source of truth is the test code and its assertions. | 247 | No visual artifacts — this surface's source of truth is the test code and its assertions. |
| 240 | </p> | 248 | </p> |
| @@ -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; | ||
| @@ -179,3 +179,10 @@ a.watch.no { · 178 unmodified lines | |||
| 179 | color: #d7dce5; | 179 | color: #d7dce5; |
| 180 | border-bottom-color: #388bfd; | 180 | border-bottom-color: #388bfd; |
| 181 | } | 181 | } |
| 182 | |||
| 183 | .cast-player { | ||
| 184 | margin-top: 12px; | ||
| 185 | border-radius: 8px; | ||
| 186 | overflow: hidden; | ||
| 187 | border: 1px solid #2a2a2a; | ||
| 188 | } | ||
| @@ -1 +1 @@ | |||
| 1 | Subproject commit 41f09e973c174d4d64c60b9f4ec271cf7ef9c2eb | 1 | Subproject commit 36638da8c104e92cb3522e0dca336618cdfd8205 |