History

Make spec-detected auth methods immutable in the add flow (#1188)

* e2e: browse/promote authoring loop and cross-OS packaged-desktop targets Two additions to the e2e setup sharing one idea: the interfaces you develop the product with are the same ones the generated tests drive, and every run is watchable. Authoring loop (src/journey/, scripts/cli.ts): - `bun run cli browse <target> <step>` drives the live web UI one step at a time (real logged-in Chromium), each step replaying the whole flow and printing the page's controls plus a screenshot. Steps span the browser, a terminal (`run`), and HTTP (`request`), interleaved in one session. - `bun run cli promote <target> "<name>"` turns the recorded journey into a committed scenario() and runs it. One Step DSL is the source of truth for both live execution and codegen, so the generated test drives the same surfaces the exploration drove and cannot quietly diverge. Cross-OS packaged desktop (setup/desktop-*, desktop-vm/, src/vm/desktop.ts): - desktop-macos / desktop-linux / desktop-windows run the real electron-builder bundle inside a guest, drive it over a CDP tunnel, and film the console into runs/<target>/ alongside test.ts and step screenshots. One shared scenario and driver; only launch and capture differ per OS: macOS: autologin Aqua session, launchctl asuser, screencapture linux: Xvfb + openbox, xdotool window resize, ffmpeg x11grab windows: dockur (QEMU) interactive session, QEMU screendump - macOS and Linux auto-provision a tart guest and build the bundle locally (the executor binary cross-compiles via BUN_TARGET); Windows attaches to a dockur host configured through E2E_DESKTOP_WIN_* env. The desktop targets are not in the default test chain and skip honestly without a guest. Also: - Force tart SSH to password-only (PubkeyAuthentication=no, IdentitiesOnly=yes) so a loaded SSH agent does not exhaust the guest's MaxAuthTries, an intermittent failure the existing cli-{os} lanes also hit. - build-sidecar keys the executable-bit chmod on the build target, not the host, so a windows-target cross-build no longer fails looking for a unix executor binary. * Make spec-detected auth methods immutable in the add flow A method seeded from spec/probe detection rendered the full None/API key/ OAuth selector and editable fields, so a user could silently switch a detected method (e.g. a Bearer-token API) to OAuth with empty endpoints and end up with a broken integration. Detected methods now render read-only in a disabled state: a lock icon, the auth kind named explicitly (OAuth / API key / No auth, since MCP seeds a detected method as just "Detected"), the declared config shown read-only, and "Pulled from spec. Remove to override." The only action is to remove the row and add a custom one; hand-added methods stay fully editable. This lives in the shared AuthMethodListEditor, so the OpenAPI and MCP add flows both get it. Detection is keyed off an explicit row flag rather than the seed slug, since MCP seeds a detected method without one. Also fix PlacementLine: its inline-flex container trimmed the whitespace at child edges, dropping the space after "Authorization:" and the trailing space in a "Bearer " prefix and rendering "Authorization:Bearer••••••". Render it as plain inline with preserved whitespace.

Rhys Sullivan committed 4 months ago

Commit da78dff1 parent 8109db119 files · +2021 −35Browse files

Unified · Split

19 files
  1. apps/desktop/scripts/build-sidecar.ts +5 −1
  2. e2e/AGENTS.md +74 −0
  3. e2e/desktop-vm/console-renders.test.ts +75 −0
  4. e2e/scripts/cli.ts +326 −0
  5. e2e/selfhost/detected-auth-immutable-ui.test.ts +185 −0
  6. e2e/setup/desktop-linux.globalsetup.ts +125 −0
  7. e2e/setup/desktop-macos.globalsetup.ts +108 −0
  8. e2e/setup/desktop-vm.ts +91 −0
  9. e2e/setup/desktop-windows.globalsetup.ts +106 −0
  10. e2e/src/journey/codegen.ts +78 −0
  11. e2e/src/journey/run.ts +104 −0
  12. e2e/src/journey/steps.ts +236 −0
  13. e2e/src/vm/desktop.ts +327 −0
  14. e2e/src/vm/tart.ts +17 −1
  15. e2e/targets/desktop.ts +4 −1
  16. e2e/targets/registry.ts +6 −0
  17. e2e/vitest.config.ts +20 −0
  18. packages/react/src/components/auth-method-list-editor.tsx +128 −31
  19. packages/react/src/lib/auth-placements.tsx +6 −1
apps/desktop/scripts/build-sidecar.ts+5−1
@@ -45,7 +45,11 @@ await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true }); · 44 unmodified lines
4545 await mkdir(EXECUTOR_OUT_DIR, { recursive: true });
4646 await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true });
4747
48−if (process.platform !== "win32") {
48+// Restore the unix executable bit — keyed on the TARGET, not the host. A
49+// windows-target cross-build (BUN_TARGET=bun-windows-x64 on macOS/linux) stages
50+// `executor.exe`, which needs no bit; chmod'ing a non-existent `executor` there
51+// would ENOENT.
52+if (!targetPackage.includes("windows")) {
4953 await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755);
5054 }
5155
e2e/AGENTS.md+74−0
@@ -130,6 +130,80 @@ When handing results to the user, follow the evidence contract in the root · 129 unmodified lines
130130 [AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);
131131 [RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics.
132132
133+## Authoring from a live browser (`browse` → `promote`)
134+
135+You don't have to hand-write a browser scenario. Drive a running instance's web
136+UI one step at a time, then turn the recorded journey into a committed scenario.
137+The generated test drives the same Browser surface the exploration drove, so it
138+is the real test, not a transcript of one — develop the flow, then crystallize
139+it.
140+
141+```sh
142+cd e2e
143+bun run cli up cloud # a live instance to develop against
144+bun run cli browse cloud goto / # each step REPLAYS the whole flow from a
145+bun run cli browse cloud click link Policies # clean browser and prints the page's controls
146+bun run cli browse cloud at-url /policies # (role · name) + a screenshot, so the next
147+bun run cli browse cloud see "No policies yet" # step is written against what's actually there
148+bun run cli promote cloud "Policies · a fresh workspace has none"
149+```
150+
151+Each `browse` replays every step so far, so what you are building is, at every
152+moment, exactly what `promote` emits — a step that doesn't reproduce fails here,
153+not in CI. Steps: `goto <path>`, `click <role> <name>`, `click-text <text>`,
154+`fill <field> <value>`, `press <key>`, and the assertions `see <text>` /
155+`at-url <substring>`. `--label "…"` names a step (it becomes the `step(...)`
156+group); `browse <target> show | undo | reset` manages the journey.
157+
158+`promote` writes `<target>/<slug>.gen.test.ts` and runs it against the live
159+instance, producing the usual run artifacts (session.mp4, step screenshots,
160+trace). A journey with no assertion is refused — a scenario must prove
161+something. From then on the file is an ordinary scenario: edit it, add API/MCP
162+checks, drop the `.gen` once it's yours. The journey itself lives in
163+`.dev/<target>.journey.json` (gitignored), not the repo.
164+
165+## Desktop targets (the app on real OSes, filmed)
166+
167+The packaged desktop app runs as its own targets, each landing in its own
168+`runs/<target>/` bucket with a video. One shared scenario (`desktop-vm/`) and the
169+shared driver (`src/vm/desktop.ts`) + setup plumbing (`setup/desktop-vm.ts`); one
170+project + globalsetup per guest OS.
171+
172+- **`desktop-packaged`** — the real electron-builder bundle on THIS machine's
173+ display (the supervised-daemon attach path). Needs a logged-in GUI session.
174+- **`desktop-macos` / `desktop-linux`** — the same bundle inside a guest VM,
175+ driven over CDP from the host and filmed. The globalsetup boots the guest
176+ (tart), builds + pushes the bundle, brings the app up with
177+ `--remote-debugging-port`, forwards it, and the scenario connects + drives +
178+ records. Provisioned automatically — or attach to a running guest with
179+ `E2E_DESKTOP_VM_IP=<ip>`:
180+
181+ ```sh
182+ vitest run --project desktop-macos # or desktop-linux
183+ ```
184+
185+The guests run tart `--no-graphics` (no host window, never steals focus) but
186+still have a usable display:
187+
188+- **macOS**: the base image's autologin reaches a real Aqua session
189+ (WindowServer/Dock/Finder). Launch the app INTO it with `sudo launchctl asuser
190+ <uid> …` (a plain SSH spawn lands in a non-GUI session); the unsigned arm64
191+ bundle is ad-hoc `codesign`'d in the guest; `screencapture` films it.
192+- **linux**: no window server, so the app renders into an `Xvfb` display with a
193+ minimal WM (`openbox` — without it the electron window never maps); the window
194+ maps tiny (10x10) so the globalsetup `xdotool`-resizes it to fill, and ffmpeg
195+ `x11grab` films it. `--no-sandbox` (the chrome-sandbox needs setuid root).
196+
197+Base images (`admin`/`admin`): `executor-macos-base` (cirruslabs sequoia, autologin)
198+and `executor-linux-base` (cirruslabs ubuntu + Xvfb/ffmpeg/openbox/xdotool +
199+electron runtime libs). The bundle's `executor` binary is cross-compiled for the
200+guest (`BUN_TARGET`), and electron-builder's `dir` target assembles the unpacked
201+app on macOS — so both bundles build on this Mac.
202+
203+Note: `desktop-packaged`'s `guiAvailable()` probe (`launchctl managername`) reads
204+"Background" over SSH even when Aqua is up, so it's host-only; the VM targets gate
205+on a CDP page target instead.
206+
133207 ## Discovering endpoints
134208
135209 - The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`
e2e/desktop-vm/console-renders.test.ts+75−0
@@ -0,0 +1,75 @@
1+// The PACKAGED desktop app, on camera, inside a GUI guest — driven over CDP from
2+// the host. ONE scenario shared by every desktop-<os> project (desktop-macos,
3+// desktop-linux): the same bundle and CDP driver, proving it renders on a guest
4+// OS and filming the actual console. The desktop-<os> globalsetup boots the
5+// guest, launches the app, forwards its --remote-debugging-port (E2E_DESKTOP_CDP_PORT)
6+// and publishes the guest IP; this scenario connects, drives, and records. The
7+// run lands in runs/<target>/ (its own per-OS bucket). Without a guest it skips
8+// honestly, like desktop-packaged without a display.
9+import { writeFileSync } from "node:fs";
10+import { join } from "node:path";
11+
12+import { expect, it } from "@effect/vitest";
13+import { Effect } from "effect";
14+
15+import { scenario } from "../src/scenario";
16+import { RunDir } from "../src/services";
17+import { CdpPage, pageWsUrl, recordGuestScreen } from "../src/vm/desktop";
18+
19+const NAME = "Desktop (packaged, in a VM) · the bundle renders its console";
20+const cdpPort = process.env.E2E_DESKTOP_CDP_PORT;
21+const guestIp = process.env.E2E_DESKTOP_VM_IP;
22+const recSeconds = Number(process.env.E2E_DESKTOP_REC_SECONDS ?? "12");
23+const os: "macos" | "linux" | "windows" =
24+ process.env.E2E_TARGET === "desktop-windows"
25+ ? "windows"
26+ : process.env.E2E_TARGET === "desktop-linux"
27+ ? "linux"
28+ : "macos";
29+
30+const run = async (runDir: string) => {
31+ const cdp = await CdpPage.connect(await pageWsUrl(Number(cdpPort)));
32+ try {
33+ await cdp.command("Runtime.enable");
34+ await cdp.command("Page.enable");
35+
36+ // Film the console while we drive it (OS-aware capture lands a playable mp4).
37+ const recording = recordGuestScreen(
38+ guestIp as string,
39+ recSeconds,
40+ join(runDir, "session.mp4"),
41+ os,
42+ );
43+
44+ // Reaching the nav proves the packaged bundle booted and connected to its
45+ // daemon on this OS.
46+ await cdp.waitForText("Integrations", 60_000).catch(() => cdp.waitForText("Settings", 60_000));
47+ writeFileSync(join(runDir, "01-console-rendered.png"), await cdp.screenshot());
48+
49+ const body = await cdp.command<{ result?: { value?: string } }>("Runtime.evaluate", {
50+ expression: "document.body.innerText",
51+ returnByValue: true,
52+ });
53+ expect(body.result?.value ?? "", "the packaged console rendered its nav").toContain(
54+ "Integrations",
55+ );
56+
57+ await recording;
58+ } finally {
59+ cdp.close();
60+ }
61+};
62+
63+if (!cdpPort || !guestIp) {
64+ it.skip(`${NAME} (needs a desktop guest — set E2E_DESKTOP_VM_IP or run the desktop-<os> project)`, () => {});
65+} else {
66+ // Literal name (not NAME) so the run's test.ts review artifact captures it.
67+ scenario(
68+ "Desktop (packaged, in a VM) · the bundle renders its console",
69+ { timeout: 180_000 },
70+ Effect.gen(function* () {
71+ const runDir = yield* RunDir;
72+ yield* Effect.promise(() => run(runDir));
73+ }),
74+ );
75+}
e2e/scripts/cli.ts+326−0
@@ -7,6 +7,8 @@ · 6 unmodified lines
77 // bun scripts/cli.ts api <target> <group.endpoint> [json]
88 // bun scripts/cli.ts mcp <target> tools | call <tool> [json]
99 // bun scripts/cli.ts ledger <target> [workos|autumn]
10+// bun scripts/cli.ts browse <target> <step…> (goto/click/see/…; show|undo|reset)
11+// bun scripts/cli.ts promote <target> "<scenario name>"
1012 // bun scripts/cli.ts logs <target>
1113 // bun scripts/cli.ts down <target>
1214 //
@@ -25,6 +27,9 @@ import { networkInterfaces } from "node:os"; · 12 unmodified lines
2527 import { join } from "node:path";
2628 import { fileURLToPath } from "node:url";
2729
30+import type { JourneyFile } from "../src/journey/codegen";
31+import type { Role, Step } from "../src/journey/steps";
32+
2833 const e2eDir = fileURLToPath(new URL("..", import.meta.url));
2934 const devDir = join(e2eDir, ".dev");
3035 const cliPath = fileURLToPath(import.meta.url);
@@ -443,6 +448,308 @@ const ledger = async (targetName: string, service = "workos") => { · 412 unmodified lines
443448 console.log(JSON.stringify(entries, null, 2));
444449 };
445450
451+// --- browser journeys ------------------------------------------------------
452+// Drive a live instance's web UI step by step. Each step appends to a journey
453+// file and REPLAYS the whole flow from a clean browser, so what you build is, at
454+// every moment, exactly what `promote` emits as a scenario — develop the flow,
455+// then crystallize it (e2e/AGENTS.md) with no translation gap. The agent can't
456+// see the screen, so every step returns the page's interactive controls (the
457+// vocabulary the next step is written against) plus a screenshot for a human.
458+
459+const journeyPaths = (target: string) => ({
460+ file: join(devDir, `${target}.journey.json`),
461+ shotsDir: join(devDir, `${target}.journey`),
462+});
463+
464+const readJourney = (target: string): JourneyFile => {
465+ try {
466+ return JSON.parse(readFileSync(journeyPaths(target).file, "utf8")) as JourneyFile;
467+ } catch {
468+ return { target, org: true, steps: [] };
469+ }
470+};
471+
472+const writeJourney = (journey: JourneyFile) => {
473+ mkdirSync(devDir, { recursive: true });
474+ writeFileSync(journeyPaths(journey.target).file, JSON.stringify(journey, null, 1));
475+};
476+
477+const STEP_ROLES = new Set<Role>([
478+ "link",
479+ "button",
480+ "heading",
481+ "textbox",
482+ "tab",
483+ "menuitem",
484+ "checkbox",
485+]);
486+
487+/** One CLI step verb → a Step. The verbs are deliberately plain English so the
488+ * journey reads like instructions: goto / click / click-text / fill / press /
489+ * see / at-url / run / request. */
490+const parseStep = (
491+ verb: string,
492+ args: ReadonlyArray<string>,
493+ opts: { readonly label?: string; readonly contains?: string },
494+): Step => {
495+ const withLabel = opts.label ? { label: opts.label } : {};
496+ const withContains = opts.contains !== undefined ? { contains: opts.contains } : {};
497+ switch (verb) {
498+ case "goto": {
499+ const path = args[0];
500+ if (!path) throw new Error("usage: browse <target> goto <path> (e.g. goto /)");
501+ return { kind: "goto", path, ...withLabel };
502+ }
503+ case "click": {
504+ const role = args[0];
505+ const name = args.slice(1).join(" ");
506+ if (!role || !name) {
507+ throw new Error(
508+ "usage: browse <target> click <role> <name> (role: " + [...STEP_ROLES].join("|") + ")",
509+ );
510+ }
511+ if (!STEP_ROLES.has(role as Role)) throw new Error(`unknown role ${JSON.stringify(role)}`);
512+ return { kind: "clickRole", role: role as Role, name, ...withLabel };
513+ }
514+ case "click-text": {
515+ const text = args.join(" ");
516+ if (!text) throw new Error("usage: browse <target> click-text <text>");
517+ return { kind: "clickText", text, ...withLabel };
518+ }
519+ case "fill": {
520+ const field = args[0];
521+ const value = args.slice(1).join(" ");
522+ if (!field || args.length < 2) throw new Error("usage: browse <target> fill <field> <value>");
523+ return { kind: "fill", field, value, ...withLabel };
524+ }
525+ case "press": {
526+ const key = args[0];
527+ if (!key) throw new Error("usage: browse <target> press <key> (e.g. press Enter)");
528+ return { kind: "press", key, ...withLabel };
529+ }
530+ case "see": {
531+ const text = args.join(" ");
532+ if (!text)
533+ throw new Error("usage: browse <target> see <text> (asserts the text is visible)");
534+ return { kind: "expectText", text, ...withLabel };
535+ }
536+ case "at-url": {
537+ const contains = args[0];
538+ if (!contains) throw new Error("usage: browse <target> at-url <substring>");
539+ return { kind: "expectUrl", contains, ...withLabel };
540+ }
541+ case "run": {
542+ const command = args.join(" ");
543+ if (!command) {
544+ throw new Error(
545+ 'usage: browse <target> run "<command>" [--contains <text>] ({base} = the instance URL)',
546+ );
547+ }
548+ return { kind: "run", command, ...withContains, ...withLabel };
549+ }
550+ case "request": {
551+ const method = (args[0] ?? "").toUpperCase();
552+ const path = args[1];
553+ if (!method || !path) {
554+ throw new Error("usage: browse <target> request <METHOD> <path> [--contains <text>]");
555+ }
556+ return { kind: "request", method, path, ...withContains, ...withLabel };
557+ }
558+ default:
559+ throw new Error(
560+ `unknown step ${JSON.stringify(verb)} — goto | click | click-text | fill | press | see | at-url | run | request`,
561+ );
562+ }
563+};
564+
565+const printJourney = (journey: JourneyFile) => {
566+ if (journey.steps.length === 0) return console.log(`${journey.target}: empty journey`);
567+ console.log(`${journey.target} journey (${journey.steps.length} steps, org=${journey.org}):`);
568+ journey.steps.forEach((step, index) => {
569+ console.log(` ${String(index + 1).padStart(2)}. ${stepLabelOf(step)}`);
570+ });
571+};
572+
573+// A local copy of the label default so printing doesn't pull in the browser
574+// module (which imports playwright). Kept trivially in sync with steps.ts.
575+const stepLabelOf = (step: Step): string => {
576+ if (step.label) return step.label;
577+ if (step.kind === "goto") return `goto ${step.path}`;
578+ if (step.kind === "clickRole") return `click ${step.role} ${JSON.stringify(step.name)}`;
579+ if (step.kind === "clickText") return `click-text ${JSON.stringify(step.text)}`;
580+ if (step.kind === "fill") return `fill ${JSON.stringify(step.field)}`;
581+ if (step.kind === "press") return `press ${step.key}`;
582+ if (step.kind === "expectText") return `see ${JSON.stringify(step.text)}`;
583+ if (step.kind === "expectUrl") return `at-url ${JSON.stringify(step.contains)}`;
584+ if (step.kind === "run") {
585+ return `run ${JSON.stringify(step.command)}${step.contains ? ` → ${JSON.stringify(step.contains)}` : ""}`;
586+ }
587+ return `request ${step.method} ${step.path}${step.contains ? ` → ${JSON.stringify(step.contains)}` : ""}`;
588+};
589+
590+const browse = async (raw: ReadonlyArray<string>) => {
591+ const target = raw[0];
592+ if (!target) throw new Error("usage: browse <target> <step…> (or show | undo | reset)");
593+
594+ // Re-parse tokens here: --label takes a value, which the top-level flag/arg
595+ // split would mangle.
596+ let label: string | undefined;
597+ let contains: string | undefined;
598+ const positional: string[] = [];
599+ const bools = new Set<string>();
600+ const tokens = raw.slice(1);
601+ for (let i = 0; i < tokens.length; i++) {
602+ const token = tokens[i]!;
603+ if (token === "--label") {
604+ label = tokens[++i];
605+ continue;
606+ }
607+ if (token.startsWith("--label=")) {
608+ label = token.slice("--label=".length);
609+ continue;
610+ }
611+ if (token === "--contains") {
612+ contains = tokens[++i];
613+ continue;
614+ }
615+ if (token.startsWith("--contains=")) {
616+ contains = token.slice("--contains=".length);
617+ continue;
618+ }
619+ if (token.startsWith("--")) {
620+ bools.add(token);
621+ continue;
622+ }
623+ positional.push(token);
624+ }
625+
626+ const verb = positional[0];
627+ const { file, shotsDir } = journeyPaths(target);
628+ let journey = readJourney(target);
629+
630+ if (verb === "show") return printJourney(journey);
631+ if (verb === "reset") {
632+ rmSync(file, { force: true });
633+ rmSync(shotsDir, { recursive: true, force: true });
634+ return console.log(`${target}: journey reset`);
635+ }
636+ if (!verb) throw new Error("usage: browse <target> <step…> (or show | undo | reset)");
637+
638+ // The flow runs as ONE minted identity per replay — same as the generated
639+ // test mints one per run, so a stateful journey behaves identically.
640+ const { target: resolved } = await loadTarget(target);
641+ const identity = await runEffect<import("../src/target").Identity>(
642+ resolved.newIdentity(journey.org ? undefined : { org: false }),
643+ );
644+ const { replayJourney } = await import("../src/journey/run");
645+
646+ let steps = [...journey.steps];
647+ if (verb === "undo") {
648+ if (steps.length === 0) throw new Error("nothing to undo");
649+ steps = steps.slice(0, -1);
650+ } else {
651+ if (steps.length === 0 && bools.has("--no-org")) journey = { ...journey, org: false };
652+ steps = [...steps, parseStep(verb, positional.slice(1), { label, contains })];
653+ }
654+
655+ mkdirSync(shotsDir, { recursive: true });
656+ const shot = join(shotsDir, `${String(steps.length).padStart(2, "0")}.png`);
657+ const observation = await replayJourney(resolved, identity, steps, { screenshotPath: shot });
658+
659+ if (observation.failedStep) {
660+ const failed = observation.failedStep;
661+ const isNew = verb !== "undo" && failed.index === steps.length - 1;
662+ console.log(
663+ `✗ ${isNew ? "this step" : `step ${failed.index + 1} (${stepLabelOf(steps[failed.index]!)})`} failed — journey unchanged.`,
664+ );
665+ console.log(` ${failed.error.split("\n")[0]}`);
666+ console.log(` screenshot: ${observation.screenshotPath}`);
667+ return;
668+ }
669+
670+ journey = { ...journey, steps };
671+ writeJourney(journey);
672+
673+ console.log(`→ ${observation.url} ${JSON.stringify(observation.title)}`);
674+ console.log(` screenshot: ${observation.screenshotPath}`);
675+ if (observation.lastOutput) {
676+ const lines = observation.lastOutput.split("\n").slice(0, 12);
677+ console.log("output:");
678+ for (const line of lines) console.log(` ${line}`);
679+ }
680+ printJourney(journey);
681+ if (observation.controls.length > 0) {
682+ console.log("controls on the page (role · name):");
683+ for (const control of observation.controls.slice(0, 40)) {
684+ console.log(` ${control.role.padEnd(9)} ${control.name}`);
685+ }
686+ if (observation.controls.length > 40) {
687+ console.log(` … ${observation.controls.length - 40} more`);
688+ }
689+ }
690+};
691+
692+const promote = async (raw: ReadonlyArray<string>) => {
693+ const target = raw[0];
694+ const positional = raw.slice(1).filter((arg) => !arg.startsWith("--"));
695+ const name = positional[0];
696+ const noRun = raw.includes("--no-run");
697+ if (!target || !name) {
698+ throw new Error('usage: promote <target> "<scenario name>" [--no-run]');
699+ }
700+ const journey = readJourney(target);
701+ if (journey.steps.length === 0) {
702+ throw new Error(`no journey for ${target} — build one with \`browse ${target} …\` first`);
703+ }
704+ const { codegenScenario, journeyHasAssertion, journeyHasBrowserStep } =
705+ await import("../src/journey/codegen");
706+ if (!journeyHasBrowserStep(journey)) {
707+ throw new Error(
708+ "this journey is all terminal/HTTP steps — `promote` generates browser-anchored scenarios. Add a browser step, or write a CLI/API test directly.",
709+ );
710+ }
711+ if (!journeyHasAssertion(journey)) {
712+ throw new Error(
713+ "this journey has no assertion (a `see`, `at-url`, `request`, or `run --contains` step), so the scenario would prove nothing. Add one, then promote.",
714+ );
715+ }
716+
717+ const source = codegenScenario(name, journey);
718+ const slug = name
719+ .toLowerCase()
720+ .replace(/[^a-z0-9]+/g, "-")
721+ .replace(/^-+|-+$/g, "")
722+ .slice(0, 80);
723+ // Scope the file to the target's own dir (cloud/, selfhost/) so it runs only
724+ // where its UI selectors apply, not on every cross-target host.
725+ const relFile = join(target, `${slug}.gen.test.ts`);
726+ writeFileSync(join(e2eDir, relFile), source);
727+ console.log(`wrote ${relFile} (${journey.steps.length} steps from the ${target} journey)`);
728+
729+ const urlKey = `E2E_${target.toUpperCase()}_URL`;
730+ const url = readState(target)?.env?.[urlKey];
731+ if (noRun || !url) {
732+ console.log(
733+ `run it against the live instance:\n ${urlKey}=<url> bunx vitest run --project ${target} ${relFile}`,
734+ );
735+ return;
736+ }
737+
738+ console.log(`running it against the live instance (${url}) …`);
739+ const proc = spawn("bunx", ["vitest", "run", "--project", target, relFile], {
740+ cwd: e2eDir,
741+ stdio: "inherit",
742+ env: { ...process.env, [urlKey]: url },
743+ });
744+ const code: number = await new Promise((resolve) => proc.on("exit", (c) => resolve(c ?? 1)));
745+ const slugDir = join("runs", target, slug);
746+ console.log(
747+ code === 0
748+ ? `\n✓ passed. Artifacts (test.ts, result.json, session.mp4/film.mp4): e2e/${slugDir}\n view: cd e2e && bun run serve → #/${target}/${slug}`
749+ : `\n✗ the generated test failed (exit ${code}). The flow that passed live did not pass as a committed test — inspect ${relFile} and e2e/${slugDir}.`,
750+ );
751+};
752+
446753 // --- lifecycle commands ----------------------------------------------------
447754
448755 const status = () => {
@@ -508,9 +815,24 @@ const HELP = `e2e dev CLI — the scenario primitives, interactive (see e2e/AGEN · 59 unmodified lines
508815 api <target> <group.endpoint> [json] typed API call as a fresh identity
509816 mcp <target> tools | call <tool> [json] MCP session call
510817 ledger <target> [workos|autumn] the emulator's request ledger (cloud)
818+ browse <target> <step> drive the live web UI, one step at a time; each step
819+ replays the whole flow and prints the page's controls
820+ steps: goto <path> | click <role> <name> | click-text <text>
821+ | fill <field> <value> | press <key>
822+ | see <text> | at-url <substring>
823+ | run "<cmd>" | request <METHOD> <path>
824+ flags: --label "…", --no-org, --contains <text>
825+ ({base} in a run command = the instance URL)
826+ browse <target> show | undo | reset
827+ promote <target> "<name>" turn the recorded journey into a committed scenario
828+ (<target>/<slug>.gen.test.ts) and run it (--no-run)
511829 logs <target> dump the instance's dev-server log
512830 down <target> tear down (kills servers, removes tailscale serves)
513831
832+A browser journey IS the scenario: develop the flow with \`browse\`, then
833+\`promote\` it. The generated test drives the same surface, so a reviewer judges
834+it by reading the test and watching its video.
835+
514836 Instances live in e2e/.dev/<target>.json — a state file marks a DELIBERATE
515837 long-lived instance. Use the booted instance for e2e too:
516838 E2E_SELFHOST_URL=<app url> vitest run --project selfhost <file>`;
@@ -534,6 +856,10 @@ const main = async () => { · 17 unmodified lines
534856 return mcpCall(args[0] ?? "", args[1], args.slice(2));
535857 case "ledger":
536858 return ledger(args[0] ?? "", args[1]);
859+ case "browse":
860+ return browse(rest);
861+ case "promote":
862+ return promote(rest);
537863 case "logs":
538864 return logs(args[0] ?? "");
539865 case "down":
e2e/selfhost/detected-auth-immutable-ui.test.ts+185−0
@@ -0,0 +1,185 @@
1+// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
2+// the add flow. The shared AuthMethodListEditor renders detected methods as a
3+// disabled, read-only summary ("Pulled from spec. Remove to override.") with no
4+// kind selector, so a user can't silently retype the spec's method into a kind
5+// nothing backs. A method the user adds by hand stays fully editable. Both the
6+// MCP and OpenAPI add flows compose the same editor, so one behavior, two
7+// surfaces. Selfhost runs with EXECUTOR_ALLOW_LOCAL_NETWORK so the probe/analyze
8+// can reach the loopback fixtures. Video is the artifact.
9+import { randomBytes } from "node:crypto";
10+import { createServer } from "node:http";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
15+import { OAuthTestServer } from "@executor-js/sdk/testing";
16+
17+import { scenario } from "../src/scenario";
18+import { Browser, Target } from "../src/services";
19+
20+const REMOVE_HINT = "Pulled from spec. Remove to override.";
21+
22+scenario(
23+ "Detected auth · an MCP probe's OAuth method is immutable in the add flow",
24+ {},
25+ Effect.scoped(
26+ Effect.gen(function* () {
27+ const target = yield* Target;
28+ const browser = yield* Browser;
29+ // OAuth-protected server: the probe 401s with resource metadata, so the
30+ // method list seeds a single detected OAuth row (discovered metadata).
31+ const server = yield* serveMcpServerWithOAuth(
32+ () => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }),
33+ { path: "/mcp" },
34+ );
35+ const identity = yield* target.newIdentity();
36+
37+ yield* browser.session(identity, async ({ page, step }) => {
38+ await step("Open the add-MCP flow pointed at the OAuth server", async () => {
39+ await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
40+ waitUntil: "networkidle",
41+ });
42+ await page.getByText("How does this server authenticate?").waitFor();
43+ await page.getByText("Method 1 · Detected").waitFor();
44+ });
45+
46+ await step("The detected method is locked: read-only, named, no selector", async () => {
47+ // The kind is named explicitly ("OAuth"), the discovered-OAuth summary
48+ // and override hint sit inside a disabled block, and there is NO
49+ // editable kind selector (the FilterTabs render as buttons).
50+ await page.getByText("OAuth", { exact: true }).first().waitFor();
51+ await page.getByText("OAuth metadata is discovered from this server").waitFor();
52+ await page.getByText(REMOVE_HINT).waitFor();
53+ expect(
54+ await page.locator("[aria-disabled]").count(),
55+ "the detected method renders a disabled (non-interactive) block",
56+ ).toBeGreaterThan(0);
57+ expect(
58+ await page.getByRole("button", { name: "API key", exact: true }).count(),
59+ "no editable kind selector is shown for the detected method",
60+ ).toBe(0);
61+ });
62+
63+ await step("A hand-added method keeps the full editable selector", async () => {
64+ await page.getByRole("button", { name: "Add method" }).click();
65+ await page.getByText("Method 2").waitFor();
66+ // The added row defaults to API key and exposes the None/API key/OAuth
67+ // kind tabs (buttons) — the detected row above still shows none.
68+ expect(
69+ await page.getByRole("button", { name: "API key", exact: true }).count(),
70+ "the added method exposes the kind selector",
71+ ).toBeGreaterThan(0);
72+ await page.getByText(REMOVE_HINT).waitFor();
73+ });
74+ });
75+ }),
76+ ).pipe(Effect.provide(OAuthTestServer.layer())),
77+);
78+
79+/** A real 127.0.0.1 server that serves a static OpenAPI spec for the add flow. */
80+const serveSpec = (body: string) =>
81+ Effect.acquireRelease(
82+ Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
83+ const server = createServer((_request, response) => {
84+ response.writeHead(200, { "content-type": "application/json" });
85+ response.end(body);
86+ });
87+ server.listen(0, "127.0.0.1", () => {
88+ const address = server.address();
89+ const port = typeof address === "object" && address ? address.port : 0;
90+ resume(
91+ Effect.succeed({
92+ url: `http://127.0.0.1:${port}/spec.json`,
93+ close: () => {
94+ server.close();
95+ server.closeAllConnections();
96+ },
97+ }),
98+ );
99+ });
100+ }),
101+ (server) => Effect.sync(server.close),
102+ );
103+
104+const apiKeyAndOAuthSpec = (): string =>
105+ JSON.stringify({
106+ openapi: "3.0.3",
107+ info: { title: "Acme Immutable Auth Fixture", version: "1.0.0" },
108+ servers: [{ url: "https://api.acme.test" }],
109+ security: [{ bearerAuth: [] }, { acmeOAuth: ["read"] }],
110+ components: {
111+ securitySchemes: {
112+ bearerAuth: { type: "http", scheme: "bearer" },
113+ acmeOAuth: {
114+ type: "oauth2",
115+ flows: {
116+ authorizationCode: {
117+ authorizationUrl: "https://api.acme.test/oauth/authorize",
118+ tokenUrl: "https://api.acme.test/oauth/token",
119+ scopes: { read: "Read access" },
120+ },
121+ },
122+ },
123+ },
124+ },
125+ paths: {
126+ "/widgets": {
127+ get: {
128+ operationId: "listWidgets",
129+ summary: "List widgets",
130+ responses: { "200": { description: "ok" } },
131+ },
132+ },
133+ },
134+ });
135+
136+scenario(
137+ "Detected auth · OpenAPI spec-detected methods are immutable in the add flow",
138+ {},
139+ Effect.scoped(
140+ Effect.gen(function* () {
141+ const target = yield* Target;
142+ const browser = yield* Browser;
143+ const spec = yield* serveSpec(apiKeyAndOAuthSpec());
144+ const identity = yield* target.newIdentity();
145+
146+ yield* browser.session(identity, async ({ page, step }) => {
147+ await step("Analyze a spec that declares both API key and OAuth", async () => {
148+ await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" });
149+ await page
150+ .getByPlaceholder(/openapi\.json/i)
151+ .first()
152+ .fill(spec.url);
153+ await page.getByText("How does this API authenticate?").waitFor();
154+ await page.getByText("Method 2").waitFor();
155+ });
156+
157+ await step("Both detected methods are locked, named, read-only", async () => {
158+ // Two detected methods, each with the override hint and its kind named
159+ // ("API key" / "OAuth"); the OAuth one shows the spec's real endpoints
160+ // read-only. No editable kind selector (FilterTabs render as buttons).
161+ expect(
162+ await page.getByText(REMOVE_HINT).count(),
163+ "both detected methods show the remove-to-override hint",
164+ ).toBe(2);
165+ await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
166+ await page.getByText("API key", { exact: true }).first().waitFor();
167+ await page.getByText("OAuth", { exact: true }).first().waitFor();
168+ expect(
169+ await page.getByRole("button", { name: "OAuth", exact: true }).count(),
170+ "no editable kind selector is shown for the detected methods",
171+ ).toBe(0);
172+ });
173+
174+ await step("A hand-added method keeps the full editable selector", async () => {
175+ await page.getByRole("button", { name: "Add method" }).click();
176+ await page.getByText("Method 3").waitFor();
177+ expect(
178+ await page.getByRole("button", { name: "API key", exact: true }).count(),
179+ "the added method exposes the kind selector",
180+ ).toBeGreaterThan(0);
181+ });
182+ });
183+ }),
184+ ),
185+);
e2e/setup/desktop-linux.globalsetup.ts+125−0
@@ -0,0 +1,125 @@
1+// desktop-linux: bring the PACKAGED app up inside a Linux guest and forward its
2+// CDP port (the shared attach/forward lives in ./desktop-vm). No window server,
3+// so the app renders into an Xvfb virtual display; ffmpeg x11grab (in the
4+// scenario's recorder) films that display. Simpler than macOS: no Aqua, no
5+// codesign, no launchctl — just background processes with DISPLAY set and
6+// --no-sandbox (the chrome-sandbox needs setuid root, pointless on a throwaway
7+// guest). The base image (executor-linux-base) carries Xvfb + ffmpeg + the
8+// electron runtime libs.
9+import { execFileSync } from "node:child_process";
10+import { existsSync } from "node:fs";
11+import { fileURLToPath } from "node:url";
12+import { basename, join } from "node:path";
13+
14+import { pushDirAsTar } from "../src/vm/desktop";
15+import { tartVm } from "../src/vm/tart";
16+import {
17+ attachOrProvision,
18+ CDP_GUEST_PORT,
19+ waitGuestHttp,
20+ waitGuestPageTarget,
21+ type ProvisionedGuest,
22+} from "./desktop-vm";
23+
24+const DAEMON_PORT = 4789;
25+const GUEST_DIR = "/home/admin/exe";
26+const GUEST_HOME = "/home/admin/exe-home";
27+const DISPLAY = ":99";
28+
29+const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
30+const hostBundle = () => {
31+ // electron-builder names the dir `linux-<arch>-unpacked` for non-x64.
32+ const dir = join(appDir, "dist", "linux-arm64-unpacked");
33+ return {
34+ dir,
35+ exe: join(dir, "executor-desktop"),
36+ executor: join(dir, "resources/executor/executor"),
37+ };
38+};
39+
40+/** Build the packaged linux-arm64 bundle if it isn't on disk. The `executor`
41+ * binary is cross-compiled here via BUN_TARGET (same as the cli-linux lane);
42+ * electron-builder's `dir` target assembles the unpacked app on macOS without
43+ * Docker. */
44+const ensureBundle = (): void => {
45+ if (existsSync(hostBundle().dir)) return;
46+ const run = (cmd: string, args: string[], env: Record<string, string> = {}) =>
47+ execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env, ...env } });
48+ run("bun", ["./scripts/build-sidecar.ts"], { BUN_TARGET: "bun-linux-arm64" });
49+ run("bunx", ["--bun", "electron-vite", "build"]);
50+ run(
51+ "bunx",
52+ [
53+ "--bun",
54+ "electron-builder",
55+ "--config",
56+ "electron-builder.e2e.config.ts",
57+ "--linux",
58+ "--arm64",
59+ ],
60+ { CSC_IDENTITY_AUTO_DISCOVERY: "false" },
61+ );
62+};
63+
64+const provisionLinux = async (): Promise<ProvisionedGuest> => {
65+ ensureBundle();
66+ const { dir } = hostBundle();
67+ const vm = await tartVm("linux", "arm64").provision();
68+ try {
69+ await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME}; mkdir -p ${GUEST_HOME}/.executor`);
70+ await pushDirAsTar(vm.host, dir, GUEST_DIR);
71+
72+ const guestApp = `${GUEST_DIR}/${basename(dir)}`;
73+ const guestExe = `${guestApp}/executor-desktop`;
74+ const guestExecutor = `${guestApp}/resources/executor/executor`;
75+ await vm.ssh(`chmod +x '${guestExe}' '${guestExecutor}' 2>/dev/null || true`);
76+ const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
77+
78+ // A virtual display + a minimal WM (openbox) — without a window manager the
79+ // electron window doesn't map onto the framebuffer that x11grab records.
80+ await vm.ssh(
81+ `pkill Xvfb 2>/dev/null; pkill openbox 2>/dev/null; ` +
82+ `nohup Xvfb ${DISPLAY} -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & sleep 2; ` +
83+ `DISPLAY=${DISPLAY} nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; echo up`,
84+ );
85+
86+ // 1) the bundled daemon, supervised — the app attaches to this.
87+ await vm.ssh(
88+ `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
89+ `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
90+ `>/tmp/executor-daemon.log 2>&1 &`,
91+ );
92+ if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
93+ throw new Error(
94+ "supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
95+ );
96+ }
97+
98+ // 2) the packaged app on the virtual display, with CDP enabled.
99+ await vm.ssh(
100+ `nohup env ${env} DISPLAY=${DISPLAY} '${guestExe}' --no-sandbox ` +
101+ `--remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins='*' ` +
102+ `>/tmp/executor-app.log 2>&1 &`,
103+ );
104+ if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
105+ const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
106+ ?.stdout;
107+ throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
108+ }
109+
110+ // The electron window maps tiny (10x10) under Xvfb; size it to the screen so
111+ // the x11grab recording captures the full console (CDP screenshots the
112+ // renderer surface regardless, but the film grabs the X framebuffer).
113+ await vm.ssh(
114+ `WID=$(DISPLAY=${DISPLAY} xdotool search --name executor-desktop | head -1); ` +
115+ `[ -n "$WID" ] && DISPLAY=${DISPLAY} xdotool windowsize "$WID" 1280 800 windowmove "$WID" 0 0 || true`,
116+ );
117+
118+ return { ip: vm.host, teardown: async () => void (await vm.discard()) };
119+ } catch (error) {
120+ await vm.discard();
121+ throw error;
122+ }
123+};
124+
125+export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionLinux);
e2e/setup/desktop-macos.globalsetup.ts+108−0
@@ -0,0 +1,108 @@
1+// desktop-macos: bring the PACKAGED app up inside a macOS GUI guest and forward
2+// its CDP port (the shared attach/forward lives in ./desktop-vm). The guest runs
3+// tart `--no-graphics` (no host window) but the base image's autologin still
4+// reaches a real Aqua session, so the GUI renders and `screencapture` films it.
5+// We come up the SAME way desktop-packaged does — start the bundled daemon, then
6+// launch the app so it ATTACHES (no sidecar spawn → no first-run consent modal).
7+// The app must be launched INTO the Aqua session (`launchctl asuser`); a plain
8+// SSH spawn lands in a non-GUI session.
9+import { execFileSync } from "node:child_process";
10+import { existsSync } from "node:fs";
11+import { fileURLToPath } from "node:url";
12+import { join } from "node:path";
13+
14+import { pushDirAsTar } from "../src/vm/desktop";
15+import { tartVm } from "../src/vm/tart";
16+import {
17+ attachOrProvision,
18+ CDP_GUEST_PORT,
19+ waitGuestHttp,
20+ waitGuestPageTarget,
21+ type ProvisionedGuest,
22+} from "./desktop-vm";
23+
24+const DAEMON_PORT = 4789;
25+const GUEST_DIR = "/Users/admin/exe";
26+const GUEST_HOME = "/Users/admin/exe-home";
27+
28+const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
29+const hostBundle = () => {
30+ const app = join(appDir, "dist", "mac-arm64", "Executor.app");
31+ return {
32+ app,
33+ exe: join(app, "Contents/MacOS/Executor"),
34+ executor: join(app, "Contents/Resources/executor/executor"),
35+ };
36+};
37+
38+/** Build the packaged mac bundle if it isn't on disk (slow; reuse an existing
39+ * dist/ while iterating). Mirrors desktop-packaged.globalsetup. */
40+const ensureBundle = (): void => {
41+ if (existsSync(hostBundle().app)) return;
42+ const run = (cmd: string, args: string[]) =>
43+ execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env } });
44+ run("bun", ["./scripts/build-sidecar.ts"]);
45+ run("bunx", ["--bun", "electron-vite", "build"]);
46+ execFileSync(
47+ "bunx",
48+ ["--bun", "electron-builder", "--config", "electron-builder.e2e.config.ts", "--mac"],
49+ {
50+ cwd: appDir,
51+ stdio: "inherit",
52+ env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" },
53+ },
54+ );
55+};
56+
57+const provisionMac = async (): Promise<ProvisionedGuest> => {
58+ ensureBundle();
59+ const { exe, executor } = hostBundle();
60+ const vm = await tartVm("macos", "arm64").provision();
61+ try {
62+ // Push the bundle (tar-stream, robust over the just-booted link) and clear
63+ // the scp quarantine so it can run.
64+ await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME} && mkdir -p ${GUEST_HOME}/.executor`);
65+ await pushDirAsTar(vm.host, hostBundle().app, GUEST_DIR);
66+ await vm.ssh(`xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true`);
67+ // The e2e build is unsigned; an arm64 app needs at least an ad-hoc signature
68+ // to execute, and the host build's signature isn't trusted on another Mac.
69+ await vm.ssh(
70+ `codesign --force --deep --sign - ${GUEST_DIR}/Executor.app 2>&1 | tail -2 || true`,
71+ );
72+
73+ const guestExe = `${GUEST_DIR}/Executor.app/${exe.split("/Executor.app/")[1]}`;
74+ const guestExecutor = `${GUEST_DIR}/Executor.app/${executor.split("/Executor.app/")[1]}`;
75+ const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
76+
77+ // 1) the bundled daemon, supervised — the app attaches to this.
78+ await vm.ssh(
79+ `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
80+ `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
81+ `>/tmp/executor-daemon.log 2>&1 &`,
82+ );
83+ if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
84+ throw new Error(
85+ "supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
86+ );
87+ }
88+
89+ // 2) the packaged app, launched INTO the Aqua session with CDP enabled.
90+ await vm.ssh(
91+ `U=$(id -u); sudo launchctl asuser $U bash -lc ` +
92+ `'nohup env HOME=${GUEST_HOME} "${guestExe}" --remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins="*" ` +
93+ `>/tmp/executor-app.log 2>&1 &'`,
94+ );
95+ if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
96+ const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
97+ ?.stdout;
98+ throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
99+ }
100+
101+ return { ip: vm.host, teardown: async () => void (await vm.discard()) };
102+ } catch (error) {
103+ await vm.discard();
104+ throw error;
105+ }
106+};
107+
108+export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionMac);
e2e/setup/desktop-vm.ts+91−0
@@ -0,0 +1,91 @@
1+// Shared plumbing for the desktop-<os> globalsetups. Each OS setup supplies a
2+// `provision` that boots its guest and brings the packaged app up with
3+// --remote-debugging-port; this module handles the rest the same everywhere:
4+// attach to an already-running guest (E2E_DESKTOP_VM_IP) or provision a fresh
5+// one, then forward the guest's CDP port and publish it for the scenario.
6+import { guestTunnel } from "../src/vm/desktop";
7+import type { VmHandle } from "../src/vm/types";
8+
9+export const CDP_GUEST_PORT = 9222;
10+
11+const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
12+
13+/** Poll until an HTTP endpoint inside the guest answers (any status — a 401 from
14+ * the bearer-gated daemon still means "up"). HTTP, not lsof: the app may be
15+ * owned by root (launchctl asuser), whose listening socket an unprivileged lsof
16+ * can't see — a loopback HTTP probe works regardless of owner. */
17+export const waitGuestHttp = async (vm: VmHandle, url: string, attempts = 60): Promise<boolean> => {
18+ for (let i = 0; i < attempts; i++) {
19+ const r = await vm.ssh(
20+ `curl -s -o /dev/null -w '%{http_code}' --max-time 5 ${url} 2>/dev/null || echo 000`,
21+ );
22+ const code = r.stdout.trim().slice(-3);
23+ if (code !== "000" && code !== "") return true;
24+ await sleep(2000);
25+ }
26+ return false;
27+};
28+
29+/** Poll until CDP advertises a real PAGE target — i.e. the app's window/renderer
30+ * is up, not just the browser endpoint. On a cold guest the page appears a good
31+ * bit after the port opens, so gating on this makes the scenario deterministic. */
32+export const waitGuestPageTarget = async (
33+ vm: VmHandle,
34+ port: number,
35+ attempts = 60,
36+): Promise<boolean> => {
37+ for (let i = 0; i < attempts; i++) {
38+ const r = await vm.ssh(
39+ `curl -s --max-time 5 http://127.0.0.1:${port}/json/list 2>/dev/null | grep -c '"type": "page"' || echo 0`,
40+ );
41+ if (Number(r.stdout.trim() || "0") > 0) return true;
42+ await sleep(2000);
43+ }
44+ return false;
45+};
46+
47+export interface ProvisionedGuest {
48+ readonly ip: string;
49+ readonly teardown: () => Promise<void>;
50+}
51+
52+/**
53+ * The body every desktop-<os>.globalsetup returns: attach to E2E_DESKTOP_VM_IP
54+ * if set, else provision a fresh guest; then forward the guest's CDP port and
55+ * publish it (+ the guest IP, for filming) for the worker. A provision/forward
56+ * failure never fails the run — the scenario skips honestly, like
57+ * desktop-packaged without a display.
58+ */
59+export const attachOrProvision = async (
60+ provision: () => Promise<ProvisionedGuest>,
61+): Promise<(() => Promise<void>) | void> => {
62+ let ip = process.env.E2E_DESKTOP_VM_IP;
63+ let teardownVm: (() => Promise<void>) | undefined;
64+
65+ if (!ip) {
66+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: VM/host setup may fail; degrade to a skip
67+ try {
68+ const result = await provision();
69+ ip = result.ip;
70+ teardownVm = result.teardown;
71+ } catch (error) {
72+ console.warn(`[desktop] provision failed, scenario will skip: ${String(error)}`);
73+ return;
74+ }
75+ }
76+
77+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: forwarding may fail; degrade to a skip
78+ try {
79+ const forward = await guestTunnel(ip, CDP_GUEST_PORT);
80+ process.env.E2E_DESKTOP_CDP_PORT = String(forward.localPort);
81+ process.env.E2E_DESKTOP_VM_IP = ip;
82+ return async () => {
83+ forward.close();
84+ await teardownVm?.();
85+ };
86+ } catch (error) {
87+ console.warn(`[desktop] could not forward CDP from ${ip}: ${String(error)}`);
88+ await teardownVm?.();
89+ return;
90+ }
91+};
e2e/setup/desktop-windows.globalsetup.ts+106−0
@@ -0,0 +1,106 @@
1+// desktop-windows: drive the PACKAGED app running in a Windows guest over CDP.
2+// Windows-in-a-VM works best with dockur (QEMU on a Linux/KVM host): autologin
3+// gives a real interactive session the app renders into, and QEMU `screendump`
4+// films the framebuffer directly — sidestepping the session-0 problem that
5+// defeats SSH-driven screenshots (the prior proof of this path).
6+//
7+// Unlike the tart targets this ATTACHES to a long-lived Windows host (the dockur
8+// guest stays up between runs, like a shared selfhost): it forwards the guest's
9+// --remote-debugging-port to the host over an SSH jump and publishes it. The
10+// shared scenario drives; the windows recorder (src/vm/desktop.ts) films via
11+// screendump. Without a reachable app it skips honestly. All connection details
12+// come from env (no baked-in host):
13+// E2E_DESKTOP_WIN_HOST (ssh alias of the docker/KVM host to jump through),
14+// _SSH_PORT (the guest's mapped OpenSSH port), _KEY, _USER; the recorder also
15+// reads _CONTAINER and _STORAGE.
16+import { spawn } from "node:child_process";
17+import net from "node:net";
18+
19+const SSH_PORT = process.env.E2E_DESKTOP_WIN_SSH_PORT ?? "2222";
20+const KEY = process.env.E2E_DESKTOP_WIN_KEY ?? "/tmp/winkey";
21+const USER = process.env.E2E_DESKTOP_WIN_USER ?? "Administrator";
22+const CDP_GUEST_PORT = 9222;
23+
24+const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
25+
26+const freePort = (): Promise<number> =>
27+ new Promise((resolve, reject) => {
28+ const srv = net.createServer();
29+ srv.on("error", reject);
30+ srv.listen(0, "127.0.0.1", () => {
31+ const port = (srv.address() as net.AddressInfo).port;
32+ srv.close(() => resolve(port));
33+ });
34+ });
35+
36+interface CdpTarget {
37+ readonly type: string;
38+ readonly webSocketDebuggerUrl?: string;
39+}
40+
41+/** Poll the forwarded port until the app advertises a CDP page target. */
42+const pageReady = async (port: number, attempts = 30): Promise<boolean> => {
43+ for (let i = 0; i < attempts; i++) {
44+ const targets = (await fetch(`http://127.0.0.1:${port}/json/list`)
45+ .then((r) => (r.ok ? r.json() : []))
46+ .catch(() => [])) as ReadonlyArray<CdpTarget>;
47+ if (targets.some((t) => t.type === "page" && t.webSocketDebuggerUrl)) return true;
48+ await sleep(2000);
49+ }
50+ return false;
51+};
52+
53+export default async function setup(): Promise<(() => Promise<void>) | void> {
54+ const host = process.env.E2E_DESKTOP_WIN_HOST;
55+ if (!host) {
56+ console.warn(
57+ "[desktop-windows] E2E_DESKTOP_WIN_HOST not set; scenario will skip. Point it at the ssh " +
58+ "alias of a dockur/KVM Windows host running the packaged app with --remote-debugging-port.",
59+ );
60+ return;
61+ }
62+ const localPort = await freePort();
63+ // mac:localPort → (jump host) → guest:9222. -p is the guest's mapped OpenSSH
64+ // port on the host; the final hop into Windows carries the -L forward.
65+ const tunnel = spawn(
66+ "ssh",
67+ [
68+ "-o",
69+ "StrictHostKeyChecking=no",
70+ "-o",
71+ "UserKnownHostsFile=/dev/null",
72+ "-o",
73+ "ConnectTimeout=12",
74+ "-o",
75+ "ServerAliveInterval=15",
76+ "-J",
77+ host,
78+ "-p",
79+ SSH_PORT,
80+ "-i",
81+ KEY,
82+ "-L",
83+ `${localPort}:127.0.0.1:${CDP_GUEST_PORT}`,
84+ "-N",
85+ `${USER}@127.0.0.1`,
86+ ],
87+ { stdio: "ignore" },
88+ );
89+
90+ if (!(await pageReady(localPort))) {
91+ tunnel.kill();
92+ console.warn(
93+ `[desktop-windows] no app/CDP reachable on the Windows host (${host}); scenario will skip. ` +
94+ `Bring up the packaged app with --remote-debugging-port=${CDP_GUEST_PORT} in the dockur guest.`,
95+ );
96+ return;
97+ }
98+
99+ process.env.E2E_DESKTOP_CDP_PORT = String(localPort);
100+ // Non-empty so the scenario runs; the windows recorder uses E2E_DESKTOP_WIN_*.
101+ process.env.E2E_DESKTOP_VM_IP = host;
102+
103+ return async () => {
104+ tunnel.kill();
105+ };
106+}
e2e/src/journey/codegen.ts+78−0
@@ -0,0 +1,78 @@
1+// Generate a committed scenario from a recorded journey. The output is a normal
2+// scenario file (the same shape a human writes, see e2e/AGENTS.md): an Effect
3+// body that yields Target + Browser, mints a fresh identity, and drives one
4+// browser session of labelled steps. Terminal (`run`) and HTTP (`request`) steps
5+// run INSIDE that session, interleaved with the UI, so page state is never lost
6+// between them. It is meant to be read and edited after generation — promotion
7+// is the START of a scenario's life, not a frozen artifact.
8+import { codegenStep, isAssertion, isBrowserStep, stepLabel, type Step } from "./steps";
9+
10+export interface JourneyFile {
11+ readonly target: string;
12+ readonly org: boolean;
13+ readonly steps: ReadonlyArray<Step>;
14+}
15+
16+const INDENT = " ";
17+
18+const stepBlock = (step: Step): string => {
19+ const label = JSON.stringify(stepLabel(step));
20+ const body = codegenStep(step)
21+ .split("\n")
22+ .map((line) => `${INDENT} ${line}`)
23+ .join("\n");
24+ return `${INDENT}await step(${label}, async () => {\n${body}\n${INDENT}});`;
25+};
26+
27+/** A journey with no assertion proves nothing; one with no browser step isn't
28+ * what this tool generates (write a CLI/API test directly). `promote` checks
29+ * both. */
30+export const journeyHasAssertion = (journey: JourneyFile): boolean =>
31+ journey.steps.some(isAssertion);
32+
33+export const journeyHasBrowserStep = (journey: JourneyFile): boolean =>
34+ journey.steps.some(isBrowserStep);
35+
36+export const codegenScenario = (name: string, journey: JourneyFile): string => {
37+ const identityArg = journey.org ? "" : "{ org: false }";
38+ const body = journey.steps.map(stepBlock).join("\n");
39+
40+ const needsExec = journey.steps.some((step) => step.kind === "run");
41+ const needsExpect = journey.steps.some(
42+ (step) => step.kind === "request" || (step.kind === "run" && step.contains !== undefined),
43+ );
44+
45+ const imports: string[] = [];
46+ if (needsExec) {
47+ imports.push(`import { execFile } from "node:child_process";`);
48+ imports.push(`import { promisify } from "node:util";`);
49+ imports.push("");
50+ }
51+ if (needsExpect) imports.push(`import { expect } from "@effect/vitest";`);
52+ imports.push(`import { Effect } from "effect";`);
53+ imports.push("");
54+ imports.push(`import { scenario } from "../src/scenario";`);
55+ imports.push(`import { Browser, Target } from "../src/services";`);
56+
57+ const execHelper = needsExec ? "\nconst execFileAsync = promisify(execFile);\n" : "";
58+
59+ return `// Generated from an interactive browser journey: \`bun scripts/cli.ts promote ${journey.target} "<name>"\`.
60+// This is now an ordinary scenario — edit it freely. It drives the same Browser
61+// surface the exploration used, so a reviewer can judge the guarantee by
62+// reading it. Re-run with: E2E_${journey.target.toUpperCase()}_URL=<url> vitest run --project ${journey.target} <this file>
63+${imports.join("\n")}
64+${execHelper}
65+scenario(
66+ ${JSON.stringify(name)},
67+ { timeout: 120_000 },
68+ Effect.gen(function* () {
69+ const target = yield* Target;
70+ const browser = yield* Browser;
71+ const identity = yield* target.newIdentity(${identityArg});
72+ yield* browser.session(identity, async ({ page, step }) => {
73+${body}
74+ });
75+ }),
76+);
77+`;
78+};
e2e/src/journey/run.ts+104−0
@@ -0,0 +1,104 @@
1+// Replay a journey live, from a clean browser, and report what the page looks
2+// like afterward. This is the development loop: each `browse` command appends a
3+// step and replays the WHOLE journey from scratch, so the flow the agent is
4+// building is, at every moment, exactly what the generated test will run — a
5+// step that doesn't reproduce fails here, not later. The returned observation
6+// (url, title, the page's interactive controls) is how the agent, which can't
7+// see the screen, decides the next step; the screenshot is for a human.
8+import { chromium, type Page } from "playwright";
9+
10+import type { Identity, Target } from "../target";
11+import { executeStep, type Step } from "./steps";
12+
13+export interface Control {
14+ readonly role: string;
15+ readonly name: string;
16+}
17+
18+export interface Observation {
19+ readonly url: string;
20+ readonly title: string;
21+ /** The interactive elements on the page, as (role, accessible name) — the
22+ * vocabulary the next clickRole/fill step is written against. */
23+ readonly controls: ReadonlyArray<Control>;
24+ readonly screenshotPath: string;
25+ /** Textual output of the last terminal/HTTP step, if any — so the agent sees
26+ * what a `run`/`request` produced, not just the page. */
27+ readonly lastOutput?: string;
28+ /** Index of the step that threw, with its message — undefined on success. */
29+ readonly failedStep?: { readonly index: number; readonly error: string };
30+}
31+
32+const OBSERVED_ROLES = ["link", "button", "textbox", "tab", "menuitem", "checkbox"] as const;
33+const PER_ROLE_CAP = 30;
34+
35+/** A compact, deduped list of the page's interactive controls. Names come from
36+ * the accessible name (text / aria-label / placeholder), trimmed. */
37+const snapshotControls = async (page: Page): Promise<Control[]> => {
38+ const seen = new Set<string>();
39+ const controls: Control[] = [];
40+ for (const role of OBSERVED_ROLES) {
41+ const elements = await page.getByRole(role).all();
42+ for (const element of elements.slice(0, PER_ROLE_CAP)) {
43+ const raw =
44+ (await element.textContent().catch(() => null))?.trim() ||
45+ (await element.getAttribute("aria-label").catch(() => null)) ||
46+ (await element.getAttribute("placeholder").catch(() => null)) ||
47+ "";
48+ const name = raw.replace(/\s+/g, " ").trim().slice(0, 70);
49+ if (!name) continue;
50+ const key = `${role}:${name}`;
51+ if (seen.has(key)) continue;
52+ seen.add(key);
53+ controls.push({ role, name });
54+ }
55+ }
56+ return controls;
57+};
58+
59+export const replayJourney = async (
60+ target: Target,
61+ identity: Identity,
62+ steps: ReadonlyArray<Step>,
63+ options: { readonly screenshotPath: string },
64+): Promise<Observation> => {
65+ const browser = await chromium.launch();
66+ let failedStep: Observation["failedStep"];
67+ try {
68+ const context = await browser.newContext({
69+ colorScheme: "dark",
70+ viewport: { width: 1280, height: 800 },
71+ baseURL: target.baseUrl,
72+ });
73+ // Same identity injection the Browser surface does, so the live page is the
74+ // logged-in page the generated test will drive.
75+ if (identity.cookies?.length) {
76+ await context.addCookies(
77+ identity.cookies.map((cookie) => ({ ...cookie, url: target.baseUrl })),
78+ );
79+ }
80+ const page = await context.newPage();
81+ let lastOutput: string | undefined;
82+ for (let index = 0; index < steps.length; index++) {
83+ try {
84+ const output = await executeStep({ page, baseUrl: target.baseUrl }, steps[index]!);
85+ if (output !== undefined) lastOutput = output;
86+ } catch (error) {
87+ failedStep = { index, error: error instanceof Error ? error.message : String(error) };
88+ break;
89+ }
90+ }
91+ await page.waitForLoadState("networkidle").catch(() => {});
92+ await page.screenshot({ path: options.screenshotPath }).catch(() => {});
93+ return {
94+ url: page.url(),
95+ title: await page.title().catch(() => ""),
96+ controls: await snapshotControls(page).catch(() => []),
97+ screenshotPath: options.screenshotPath,
98+ lastOutput,
99+ failedStep,
100+ };
101+ } finally {
102+ await browser.close();
103+ }
104+};
e2e/src/journey/steps.ts+236−0
@@ -0,0 +1,236 @@
1+// The journey Step DSL: the single source of truth shared by interactive
2+// exploration and the generated scenario. ONE step description is both
3+// (a) executed live against the real product while the agent develops a flow
4+// (`executeStep`), and (b) emitted as the matching Playwright line inside a
5+// committed scenario (`codegenStep`). Because both sides read the same record,
6+// "turn what I just did into a test" is a translation, not a reimplementation:
7+// the generated test drives the exact surface the exploration drove.
8+//
9+// Steps are plain JSON (they persist to .dev/<target>.journey.json between CLI
10+// invocations), so they carry no closures — every action is a named primitive.
11+import { execFile } from "node:child_process";
12+import { promisify } from "node:util";
13+
14+import type { Page } from "playwright";
15+
16+const execFileAsync = promisify(execFile);
17+
18+/** ARIA roles the journey can target. A closed set keeps codegen honest and
19+ * the live `getByRole` calls type-safe (Playwright's role arg is a union). */
20+export type Role = "link" | "button" | "heading" | "textbox" | "tab" | "menuitem" | "checkbox";
21+
22+export type Step =
23+ | { readonly kind: "goto"; readonly path: string; readonly label?: string }
24+ | {
25+ readonly kind: "clickRole";
26+ readonly role: Role;
27+ readonly name: string;
28+ readonly label?: string;
29+ }
30+ | { readonly kind: "clickText"; readonly text: string; readonly label?: string }
31+ | {
32+ readonly kind: "fill";
33+ readonly field: string;
34+ readonly value: string;
35+ readonly label?: string;
36+ }
37+ | { readonly kind: "press"; readonly key: string; readonly label?: string }
38+ | { readonly kind: "expectText"; readonly text: string; readonly label?: string }
39+ | { readonly kind: "expectUrl"; readonly contains: string; readonly label?: string }
40+ // A terminal command. `{base}` expands to the target's base URL, so a journey
41+ // can hit the same instance the UI is driving (curl, npx add-mcp, executor …).
42+ // `contains` (when set) asserts on the combined stdout+stderr.
43+ | {
44+ readonly kind: "run";
45+ readonly command: string;
46+ readonly contains?: string;
47+ readonly label?: string;
48+ }
49+ // An HTTP call through the page's own authenticated session (relative paths
50+ // resolve against the base URL). `contains` asserts on the response body;
51+ // without it, the assertion is a 2xx.
52+ | {
53+ readonly kind: "request";
54+ readonly method: string;
55+ readonly path: string;
56+ readonly contains?: string;
57+ readonly label?: string;
58+ };
59+
60+/** Assertions are the steps a reviewer reads as the guarantee — a journey with
61+ * none asserts nothing, so `promote` refuses it. A `run`/`request` is an
62+ * assertion when it carries an expectation (`contains`, or `request`'s 2xx). */
63+export const isAssertion = (step: Step): boolean =>
64+ step.kind === "expectText" ||
65+ step.kind === "expectUrl" ||
66+ step.kind === "request" ||
67+ (step.kind === "run" && step.contains !== undefined);
68+
69+export const isBrowserStep = (step: Step): boolean =>
70+ step.kind !== "run" && step.kind !== "request";
71+
72+/** The human-readable step name (the `step(label, …)` group + screenshot
73+ * caption). The agent can override per step; this is the sensible default so a
74+ * generated test reads as a journey even when labels were left implicit. */
75+export const stepLabel = (step: Step): string => {
76+ if (step.label) return step.label;
77+ switch (step.kind) {
78+ case "goto":
79+ return `Open ${step.path}`;
80+ case "clickRole":
81+ return `Click the ${JSON.stringify(step.name)} ${step.role}`;
82+ case "clickText":
83+ return `Click ${JSON.stringify(step.text)}`;
84+ case "fill":
85+ return `Fill ${JSON.stringify(step.field)}`;
86+ case "press":
87+ return `Press ${step.key}`;
88+ case "expectText":
89+ return `See ${JSON.stringify(step.text)}`;
90+ case "expectUrl":
91+ return `Land on a URL containing ${JSON.stringify(step.contains)}`;
92+ case "run":
93+ return step.contains
94+ ? `Run ${JSON.stringify(step.command)} and see ${JSON.stringify(step.contains)}`
95+ : `Run ${JSON.stringify(step.command)}`;
96+ case "request":
97+ return step.contains
98+ ? `${step.method} ${step.path} returns ${JSON.stringify(step.contains)}`
99+ : `${step.method} ${step.path} succeeds`;
100+ }
101+};
102+
103+const ASSERT_TIMEOUT = 15_000;
104+
105+export interface StepContext {
106+ readonly page: Page;
107+ /** The target's base URL — `{base}` in a `run` command expands to this. */
108+ readonly baseUrl: string;
109+}
110+
111+/** Expand `{base}` so a terminal command can reach the instance under test. */
112+const withBase = (command: string, baseUrl: string): string =>
113+ command.replaceAll("{base}", baseUrl);
114+
115+/** Drive one step against a live page. Assertions throw on failure (a missing
116+ * text, a wrong URL, a non-matching command output) so it surfaces immediately
117+ * while exploring, the same way it would fail the generated test. Returns any
118+ * textual output (terminal / HTTP) so the caller can show it. */
119+export const executeStep = async (ctx: StepContext, step: Step): Promise<string | undefined> => {
120+ const { page } = ctx;
121+ switch (step.kind) {
122+ case "goto":
123+ await page.goto(step.path, { waitUntil: "networkidle" });
124+ return;
125+ case "clickRole":
126+ await page.getByRole(step.role, { name: step.name }).first().click();
127+ return;
128+ case "clickText":
129+ await page.getByText(step.text).first().click();
130+ return;
131+ case "fill":
132+ await page.getByLabel(step.field).first().fill(step.value);
133+ return;
134+ case "press":
135+ await page.keyboard.press(step.key);
136+ return;
137+ case "expectText":
138+ await page
139+ .getByText(step.text)
140+ .first()
141+ .waitFor({ state: "visible", timeout: ASSERT_TIMEOUT });
142+ return;
143+ case "expectUrl":
144+ await page.waitForURL((url) => url.toString().includes(step.contains), {
145+ timeout: ASSERT_TIMEOUT,
146+ });
147+ return;
148+ case "run": {
149+ const result = await execFileAsync("sh", ["-c", withBase(step.command, ctx.baseUrl)]).catch(
150+ (error: { stdout?: string; stderr?: string }) => ({
151+ stdout: error.stdout ?? "",
152+ stderr: error.stderr ?? String(error),
153+ }),
154+ );
155+ const output = `${result.stdout}${result.stderr}`;
156+ if (step.contains !== undefined && !output.includes(step.contains)) {
157+ throw new Error(
158+ `\`run\` output did not contain ${JSON.stringify(step.contains)}\n${output.slice(0, 1000)}`,
159+ );
160+ }
161+ return output.trim().slice(0, 2000);
162+ }
163+ case "request": {
164+ const response = await page.request.fetch(step.path, { method: step.method });
165+ const body = await response.text();
166+ if (step.contains !== undefined) {
167+ if (!body.includes(step.contains)) {
168+ throw new Error(
169+ `${step.method} ${step.path} body did not contain ${JSON.stringify(step.contains)} (status ${response.status()})`,
170+ );
171+ }
172+ } else if (!response.ok()) {
173+ throw new Error(`${step.method} ${step.path} returned ${response.status()}`);
174+ }
175+ return `${response.status()} ${body.slice(0, 800)}`;
176+ }
177+ }
178+};
179+
180+/** The Playwright line(s) for this step, as they appear inside the generated
181+ * scenario's `step(label, async () => { … })` body. Mirrors `executeStep`
182+ * exactly — same locator, same call — so live behavior and the test match. */
183+export const codegenStep = (step: Step): string => {
184+ const s = (value: string): string => JSON.stringify(value);
185+ switch (step.kind) {
186+ case "goto":
187+ return `await page.goto(${s(step.path)}, { waitUntil: "networkidle" });`;
188+ case "clickRole":
189+ return `await page.getByRole(${s(step.role)}, { name: ${s(step.name)} }).first().click();`;
190+ case "clickText":
191+ return `await page.getByText(${s(step.text)}).first().click();`;
192+ case "fill":
193+ return `await page.getByLabel(${s(step.field)}).first().fill(${s(step.value)});`;
194+ case "press":
195+ return `await page.keyboard.press(${s(step.key)});`;
196+ case "expectText":
197+ // The repo's browser-assertion idiom: waiting for the element IS the
198+ // assertion (a timeout fails the step with the locator in the message).
199+ return `await page.getByText(${s(step.text)}).first().waitFor();`;
200+ case "expectUrl":
201+ return `await page.waitForURL((url) => url.toString().includes(${s(step.contains)}));`;
202+ case "run": {
203+ const lines = [
204+ `const { stdout } = await execFileAsync("sh", ["-c", ${backtick(step.command)}]);`,
205+ ];
206+ if (step.contains !== undefined) {
207+ lines.push(
208+ `expect(stdout, "the command output is as expected").toContain(${s(step.contains)});`,
209+ );
210+ }
211+ return lines.join("\n");
212+ }
213+ case "request": {
214+ const lines = [
215+ `const response = await page.request.fetch(${s(step.path)}, { method: ${s(step.method)} });`,
216+ ];
217+ lines.push(
218+ step.contains !== undefined
219+ ? `expect(await response.text(), "the response is as expected").toContain(${s(step.contains)});`
220+ : `expect(response.ok(), "the request succeeded").toBe(true);`,
221+ );
222+ return lines.join("\n");
223+ }
224+ }
225+};
226+
227+/** A terminal command as a template literal so `{base}` becomes `target.baseUrl`
228+ * (which is in scope in the generated body). Backticks in the command are
229+ * escaped so the literal stays valid. */
230+const backtick = (command: string): string =>
231+ "`" +
232+ command
233+ .replaceAll("\\", "\\\\")
234+ .replaceAll("`", "\\`")
235+ .replaceAll("{base}", "${target.baseUrl}") +
236+ "`";
e2e/src/vm/desktop.ts+327−0
@@ -0,0 +1,327 @@
1+// Driving the PACKAGED desktop app inside a GUI guest, from the host. This is
2+// the shared substrate for the cross-OS desktop targets (Gap A): SSH plumbing,
3+// an SSH local-forward, a minimal CDP page client, and screen recording — the
4+// pieces proven against a tart macOS guest. The desktop-<os> globalsetup boots
5+// the guest and launches the app; a scenario connects over CDP and records.
6+//
7+// Why these mechanics (macOS): a tart `--no-graphics` guest opens no host window
8+// (no focus stealing) yet, with the base image's autologin, still reaches a real
9+// Aqua session (WindowServer/Dock/Finder) the app can render into. A GUI app must
10+// be launched INTO that session (`sudo launchctl asuser <uid> …`); a plain SSH
11+// spawn lands in a non-GUI session. The app's --remote-debugging-port is then
12+// reachable over an SSH forward, and `screencapture` films the console.
13+import { execFile, spawn } from "node:child_process";
14+import net from "node:net";
15+import { basename, dirname } from "node:path";
16+import { promisify } from "node:util";
17+
18+const execFileP = promisify(execFile);
19+
20+const SSHPASS = process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass";
21+const GUEST_PASS = process.env.E2E_DESKTOP_VM_PASS ?? "admin";
22+const GUEST_USER = process.env.E2E_DESKTOP_VM_USER ?? "admin";
23+const SSH_OPTS = [
24+ "-o",
25+ "StrictHostKeyChecking=no",
26+ "-o",
27+ "UserKnownHostsFile=/dev/null",
28+ "-o",
29+ "ConnectTimeout=8",
30+ "-o",
31+ "LogLevel=ERROR",
32+ // Password auth only (sshpass): a loaded SSH agent's keys would otherwise
33+ // exhaust the guest's MaxAuthTries before the password is tried.
34+ "-o",
35+ "PubkeyAuthentication=no",
36+ "-o",
37+ "IdentitiesOnly=yes",
38+];
39+
40+export const sleep = (ms: number): Promise<void> =>
41+ new Promise((resolve) => setTimeout(resolve, ms));
42+
43+export const guestSsh = (
44+ ip: string,
45+ command: string,
46+): Promise<{ stdout: string; stderr: string }> =>
47+ execFileP(SSHPASS, ["-p", GUEST_PASS, "ssh", ...SSH_OPTS, `${GUEST_USER}@${ip}`, command], {
48+ maxBuffer: 64 * 1024 * 1024,
49+ });
50+
51+export const guestScpFrom = (ip: string, remote: string, local: string): Promise<unknown> =>
52+ execFileP(SSHPASS, [
53+ "-p",
54+ GUEST_PASS,
55+ "scp",
56+ ...SSH_OPTS,
57+ `${GUEST_USER}@${ip}:${remote}`,
58+ local,
59+ ]);
60+
61+/**
62+ * Push a directory into the guest by streaming a tar over ssh: one connection,
63+ * no per-file round-trips, and the flowing data keeps the link alive — far more
64+ * robust than `scp -r` of a big app bundle (thousands of files + symlinks),
65+ * which drops mid-transfer on a freshly-booted guest. Retries once. The dir
66+ * lands at `${remoteParent}/${basename(localDir)}`.
67+ */
68+export const pushDirAsTar = async (
69+ ip: string,
70+ localDir: string,
71+ remoteParent: string,
72+): Promise<void> => {
73+ const parent = dirname(localDir);
74+ const base = basename(localDir);
75+ const remote = `${SSHPASS} -p ${GUEST_PASS} ssh ${SSH_OPTS.join(" ")} ${GUEST_USER}@${ip} ${JSON.stringify(
76+ `mkdir -p ${remoteParent} && tar xf - -C ${remoteParent}`,
77+ )}`;
78+ const pipeline = `tar cf - -C ${JSON.stringify(parent)} ${JSON.stringify(base)} | ${remote}`;
79+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one retry over a flaky just-booted guest link
80+ try {
81+ await execFileP("sh", ["-c", pipeline], { maxBuffer: 16 * 1024 * 1024 });
82+ } catch {
83+ await sleep(3000);
84+ await execFileP("sh", ["-c", pipeline], { maxBuffer: 16 * 1024 * 1024 });
85+ }
86+};
87+
88+const freePort = (): Promise<number> =>
89+ new Promise((resolve, reject) => {
90+ const srv = net.createServer();
91+ srv.on("error", reject);
92+ srv.listen(0, "127.0.0.1", () => {
93+ const port = (srv.address() as net.AddressInfo).port;
94+ srv.close(() => resolve(port));
95+ });
96+ });
97+
98+export interface Forward {
99+ readonly localPort: number;
100+ close(): void;
101+}
102+
103+/** SSH local-forward host:localPort → guest:guestPort; resolves once it binds. */
104+export const guestTunnel = async (ip: string, guestPort: number): Promise<Forward> => {
105+ const localPort = await freePort();
106+ const child = spawn(
107+ SSHPASS,
108+ [
109+ "-p",
110+ GUEST_PASS,
111+ "ssh",
112+ ...SSH_OPTS,
113+ "-N",
114+ "-L",
115+ `${localPort}:127.0.0.1:${guestPort}`,
116+ `${GUEST_USER}@${ip}`,
117+ ],
118+ { stdio: "ignore" },
119+ );
120+ for (let i = 0; i < 40; i++) {
121+ const ok = await new Promise<boolean>((resolve) => {
122+ const sock = net.connect({ host: "127.0.0.1", port: localPort }, () => {
123+ sock.destroy();
124+ resolve(true);
125+ });
126+ sock.on("error", () => resolve(false));
127+ sock.setTimeout(1000, () => {
128+ sock.destroy();
129+ resolve(false);
130+ });
131+ });
132+ if (ok) break;
133+ await sleep(500);
134+ }
135+ return { localPort, close: () => child.kill() };
136+};
137+
138+const guestFileSize = (ip: string, remote: string): Promise<number> =>
139+ guestSsh(ip, `stat -f%z ${remote} 2>/dev/null || stat -c%s ${remote} 2>/dev/null || echo 0`)
140+ .then((r) => Number(r.stdout.trim() || "0"))
141+ .catch(() => 0);
142+
143+/**
144+ * Film the guest's screen for `seconds` and land it on the host as `localMp4`
145+ * (mp4, plays everywhere). OS-aware capture:
146+ * • macOS — `screencapture -V` to a .mov, then host-side ffmpeg to mp4. The
147+ * first capture after a cold display can silently no-op, so warm it with a
148+ * throwaway still and verify+retry.
149+ * • linux — ffmpeg `x11grab` of the Xvfb display straight to mp4.
150+ * Best-effort: failures never throw — "every run is watchable" wants the video,
151+ * but a missing one shouldn't fail the run. Run it concurrently with the drive.
152+ */
153+export const recordGuestScreen = async (
154+ ip: string,
155+ seconds: number,
156+ localMp4: string,
157+ os: "macos" | "linux" | "windows",
158+): Promise<void> => {
159+ if (os === "windows") {
160+ // Windows can't screenshot the interactive desktop from an SSH session, so
161+ // we film the VM framebuffer directly via QEMU's `screendump` (the dockur
162+ // host runs the loop + ffmpeg; we pull the mp4). Host/container/storage come
163+ // from env (no baked-in host); best-effort, so skip filming if unconfigured.
164+ const host = process.env.E2E_DESKTOP_WIN_HOST;
165+ const storage = process.env.E2E_DESKTOP_WIN_STORAGE;
166+ if (!host || !storage) return;
167+ const container = process.env.E2E_DESKTOP_WIN_CONTAINER ?? "exec-win";
168+ const frames = Math.max(8, seconds * 4);
169+ const py = `import socket,time
170+s=socket.socket(socket.AF_UNIX); s.connect("/run/shm/monitor.sock"); time.sleep(0.2); s.recv(65536)
171+for i in range(${frames}):
172+ s.sendall(("screendump /storage/frames/f%03d.ppm\\n"%i).encode()); time.sleep(0.2)
173+ try: s.recv(65536)
174+ except Exception: pass`;
175+ const b64 = Buffer.from(py).toString("base64");
176+ const remote =
177+ `S=${storage}; rm -rf "$S/frames"; mkdir -p "$S/frames"; ` +
178+ `docker exec ${container} python3 -c "import base64;exec(base64.b64decode('${b64}'))"; ` +
179+ `ffmpeg -y -framerate 4 -i "$S/frames/f%03d.ppm" -pix_fmt yuv420p -movflags +faststart "$S/win.mp4" >/dev/null 2>&1`;
180+ await execFileP("ssh", ["-o", "ConnectTimeout=10", host, remote], {
181+ maxBuffer: 16 * 1024 * 1024,
182+ }).catch(() => undefined);
183+ await execFileP("scp", [
184+ "-o",
185+ "ConnectTimeout=10",
186+ `${host}:${storage}/win.mp4`,
187+ localMp4,
188+ ]).catch(() => undefined);
189+ return;
190+ }
191+
192+ if (os === "linux") {
193+ const remote = "/tmp/executor-desktop-vm.mp4";
194+ await guestSsh(
195+ ip,
196+ `rm -f ${remote}; DISPLAY=:99 ffmpeg -y -f x11grab -video_size 1280x800 -framerate 15 ` +
197+ `-i :99 -t ${seconds} -pix_fmt yuv420p ${remote} >/tmp/ffmpeg.log 2>&1`,
198+ ).catch(() => undefined);
199+ // The mostly-flat console compresses small under x264 — a real capture is
200+ // ~30-60KB, a blank/failed one only a few KB.
201+ if ((await guestFileSize(ip, remote)) > 12_000) {
202+ await guestScpFrom(ip, remote, localMp4).catch(() => undefined);
203+ }
204+ return;
205+ }
206+
207+ const remoteMov = "/tmp/executor-desktop-vm.mov";
208+ // Warm the capture subsystem — the first screencapture after the display comes
209+ // up can produce nothing.
210+ await guestSsh(ip, "screencapture -x /tmp/.warm.png 2>/dev/null; rm -f /tmp/.warm.png").catch(
211+ () => undefined,
212+ );
213+ for (let attempt = 0; attempt < 2; attempt++) {
214+ await guestSsh(ip, `rm -f ${remoteMov}; screencapture -V ${seconds} -x ${remoteMov}`).catch(
215+ () => undefined,
216+ );
217+ if ((await guestFileSize(ip, remoteMov)) > 100_000) {
218+ const localMov = `${localMp4}.mov`;
219+ await guestScpFrom(ip, remoteMov, localMov).catch(() => undefined);
220+ await execFileP("ffmpeg", [
221+ "-y",
222+ "-i",
223+ localMov,
224+ "-c:v",
225+ "libx264",
226+ "-pix_fmt",
227+ "yuv420p",
228+ "-movflags",
229+ "+faststart",
230+ localMp4,
231+ ])
232+ .then(() => execFileP("rm", ["-f", localMov]))
233+ .catch(() => undefined);
234+ return;
235+ }
236+ }
237+};
238+
239+// --- a minimal CDP page client (same protocol as desktop-packaged's driver) --
240+
241+interface CdpTarget {
242+ readonly type: string;
243+ readonly webSocketDebuggerUrl?: string;
244+}
245+
246+export class CdpPage {
247+ private nextId = 1;
248+ private readonly pending = new Map<number, (value: unknown) => void>();
249+
250+ private constructor(private readonly socket: WebSocket) {
251+ socket.addEventListener("message", (event) => {
252+ if (typeof event.data !== "string") return;
253+ const message = JSON.parse(event.data) as { id?: number; result?: unknown };
254+ if (message.id && this.pending.has(message.id)) {
255+ this.pending.get(message.id)!(message.result);
256+ this.pending.delete(message.id);
257+ }
258+ });
259+ }
260+
261+ static connect = (url: string): Promise<CdpPage> =>
262+ new Promise((resolve, reject) => {
263+ const socket = new WebSocket(url);
264+ const timer = setTimeout(
265+ // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
266+ () => reject(new Error(`CDP connect timeout: ${url}`)),
267+ 30_000,
268+ );
269+ socket.addEventListener("open", () => {
270+ clearTimeout(timer);
271+ resolve(new CdpPage(socket));
272+ });
273+ socket.addEventListener("error", () => {
274+ clearTimeout(timer);
275+ // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
276+ reject(new Error(`CDP connect failed: ${url}`));
277+ });
278+ });
279+
280+ command = <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => {
281+ const id = this.nextId++;
282+ const result = new Promise<T>((resolve) =>
283+ this.pending.set(id, (value) => resolve(value as T)),
284+ );
285+ this.socket.send(JSON.stringify({ id, method, params }));
286+ return result;
287+ };
288+
289+ waitForText = async (text: string, timeoutMs: number): Promise<void> => {
290+ const deadline = Date.now() + timeoutMs;
291+ const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`;
292+ for (;;) {
293+ const r = await this.command<{ result?: { value?: boolean } }>("Runtime.evaluate", {
294+ expression,
295+ returnByValue: true,
296+ });
297+ if (r.result?.value) return;
298+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: a wait timeout is a plain failure here
299+ if (Date.now() >= deadline) throw new Error(`timed out waiting for text: ${text}`);
300+ await sleep(250);
301+ }
302+ };
303+
304+ screenshot = async (): Promise<Buffer> => {
305+ const r = await this.command<{ data: string }>("Page.captureScreenshot", { format: "png" });
306+ return Buffer.from(r.data, "base64");
307+ };
308+
309+ close = (): void => this.socket.close();
310+}
311+
312+/** The first drivable page target's WebSocket URL, fetched through the forward
313+ * (so the returned ws URL already points at the local port). */
314+export const pageWsUrl = async (localPort: number): Promise<string> => {
315+ const deadline = Date.now() + 60_000;
316+ for (;;) {
317+ const targets = (await fetch(`http://127.0.0.1:${localPort}/json/list`)
318+ .then((r) => (r.ok ? r.json() : []))
319+ .catch(() => [])) as ReadonlyArray<CdpTarget>;
320+ const page = targets.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
321+ if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
322+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: setup failure surfaced to the caller
323+ if (Date.now() >= deadline)
324+ throw new Error("no CDP page target (app not running with --remote-debugging-port?)");
325+ await sleep(500);
326+ }
327+};
e2e/src/vm/tart.ts+17−1
@@ -30,6 +30,14 @@ const SSH_OPTS = [ · 29 unmodified lines
3030 "ServerAliveInterval=5",
3131 "-o",
3232 "LogLevel=ERROR",
33+ // We authenticate with sshpass (password). A loaded SSH agent would otherwise
34+ // offer its keys first and exhaust the guest's MaxAuthTries ("Too many
35+ // authentication failures") before the password is tried — intermittently,
36+ // depending on how many keys the agent holds. Force password-only.
37+ "-o",
38+ "PubkeyAuthentication=no",
39+ "-o",
40+ "IdentitiesOnly=yes",
3341 ];
3442 const GUEST_USER = "admin";
3543 const GUEST_PASS = "admin";
@@ -92,7 +100,15 @@ export const tartVm = (os: "macos" | "linux", arch: VmArch = "arm64"): VmProvide · 56 unmodified lines
92100 provision: async () => {
93101 const name = `executor-e2e-${os}-${process.pid}-${Math.floor(performance.now())}`;
94102 await execFileP(TART, ["clone", baseImage(os), name]);
95− const runProc = spawn(TART, ["run", name, "--no-graphics"], { stdio: "ignore" });
103+ // `--no-graphics` opens NO host window (never steals focus) yet the guest
104+ // still has a virtual display: with the base image's autologin it reaches a
105+ // real Aqua session (WindowServer/Dock/Finder), so even the packaged GUI app
106+ // renders and `screencapture` records it. No windowed/VNC mode is needed.
107+ const runProc = spawn(TART, ["run", name, "--no-graphics"], {
108+ stdio: "ignore",
109+ detached: true,
110+ });
111+ runProc.unref();
96112
97113 const tunnelClosers: Array<() => void> = [];
98114 let ip = "";
e2e/targets/desktop.ts+4−1
@@ -9,7 +9,10 @@ import { Effect } from "effect"; · 8 unmodified lines
99 import type { Target } from "../src/target";
1010
1111 export const desktopTarget = (): Target => ({
12− name: "desktop",
12+ // The project name (desktop / desktop-packaged / desktop-macos) so each lands
13+ // in its own runs/<target>/ bucket and viewer column — they're the same app
14+ // in different harnesses (dev electron / packaged / packaged-in-a-VM).
15+ name: process.env.E2E_TARGET ?? "desktop",
1316 baseUrl: "",
1417 mcpUrl: "",
1518 capabilities: new Set(),
e2e/targets/registry.ts+6−0
@@ -19,6 +19,12 @@ const factories: Record<string, () => Target> = { · 18 unmodified lines
1919 // The packaged desktop bundle launches its own app per scenario, same as
2020 // `desktop` — no standard surfaces to carry. See desktop-packaged.globalsetup.
2121 "desktop-packaged": desktopTarget,
22+ // The packaged bundle inside a GUI guest (one per OS), driven over CDP from
23+ // the host. Carries no surfaces (the scenario drives CDP itself). See
24+ // desktop-<os>.globalsetup.
25+ "desktop-macos": desktopTarget,
26+ "desktop-linux": desktopTarget,
27+ "desktop-windows": desktopTarget,
2228 local: localTarget,
2329 // The supervised CLI daemon inside a VM, one project per guest OS — restart()
2430 // is a real reboot. See setup/cli.globalsetup.ts.
e2e/vitest.config.ts+20−0
@@ -77,6 +77,26 @@ export default defineConfig({ · 76 unmodified lines
7777 testTimeout: 360_000,
7878 hookTimeout: 600_000,
7979 }),
80+ // The packaged desktop app inside a GUI guest, driven over CDP from the
81+ // host and filmed (the cross-OS counterpart of desktop-packaged) — one
82+ // shared scenario (desktop-vm/), one project per guest OS. The globalsetup
83+ // provisions the guest, launches the bundle with --remote-debugging-port,
84+ // and forwards it; the scenario connects, drives, and records the console.
85+ // Each lands in runs/<target>/. Not in the default `npm run test` chain —
86+ // run with `vitest run --project desktop-macos` (or desktop-linux). The VM
87+ // is provisioned automatically; set E2E_DESKTOP_VM_IP to attach to an
88+ // already-running guest instead.
89+ // macos/linux provision a tart guest and build+push the ~450MB bundle;
90+ // windows ATTACHES to a long-lived dockur guest over an SSH jump (no
91+ // provision), so it needs no build but the same generous hooks.
92+ ...(["macos", "linux", "windows"] as const).map((os) =>
93+ project(`desktop-${os}`, {
94+ include: ["desktop-vm/**/*.test.ts"],
95+ fileParallelism: false,
96+ testTimeout: 300_000,
97+ hookTimeout: 900_000,
98+ }),
99+ ),
80100 // The single-user local app. Each scenario launches its OWN `executor
81101 // web` via the CLI on a throwaway data dir + an OS-assigned port, so
82102 // there is no shared instance and scenarios are independent. Files run
packages/react/src/components/auth-method-list-editor.tsx+128−31
@@ -12,8 +12,9 @@ · 11 unmodified lines
1212 // ---------------------------------------------------------------------------
1313
1414 import { useCallback, useEffect, useRef, useState } from "react";
15−import { PlusIcon, XIcon } from "lucide-react";
15+import { LockIcon, PlusIcon, XIcon } from "lucide-react";
1616
17+import { PlacementLine } from "../lib/auth-placements";
1718 import { Button } from "./button";
1819 import { FieldLabel } from "./field";
1920 import {
@@ -35,6 +36,11 @@ export interface AuthMethodSeed { · 15 unmodified lines
3536
3637 export interface AuthMethodRow {
3738 readonly value: AuthTemplateEditorValue;
39+ /** True when this row came from detection (a seed), false when the user added
40+ * it. Detected rows are immutable — the spec/probe declared them — so the
41+ * editor renders them read-only. Not inferred from `seedSlug`: some plugins
42+ * (MCP) seed a detected method with a label but no slug. */
43+ readonly seeded: boolean;
3844 readonly seedSlug?: string;
3945 readonly seedLabel?: string;
4046 }
@@ -59,6 +65,7 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL · 18 unmodified lines
5965 seeds.map(
6066 (seed: AuthMethodSeed): AuthMethodRow => ({
6167 value: seed.value,
68+ seeded: true,
6269 ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),
6370 ...(seed.label !== undefined ? { seedLabel: seed.label } : {}),
6471 }),
@@ -79,7 +86,10 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL · 14 unmodified lines
7986 }, []);
8087
8188 const addRow = useCallback(() => {
82− setRows((current: readonly AuthMethodRow[]) => [...current, { value: emptyApiKeyValue() }]);
89+ setRows((current: readonly AuthMethodRow[]) => [
90+ ...current,
91+ { value: emptyApiKeyValue(), seeded: false },
92+ ]);
8393 }, []);
8494
8595 return { rows, setRowAt, removeRowAt, addRow };
@@ -115,36 +125,53 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines
115125 ) : null
116126 ) : (
117127 <div className="flex flex-col gap-3">
118− {list.rows.map((row: AuthMethodRow, index: number) => (
119− <div
120− key={index}
121− className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
122− >
123− <div className="flex items-center justify-between">
124− <span className="text-xs font-medium text-muted-foreground">
125− Method {index + 1}
126− {row.seedLabel ? ` · ${row.seedLabel}` : ""}
127− </span>
128− <Button
129− type="button"
130− variant="ghost"
131− size="icon-sm"
132− aria-label="Remove method"
133− className="text-muted-foreground hover:text-foreground"
134− onClick={() => list.removeRowAt(index)}
135− >
136− <XIcon />
137− </Button>
128+ {list.rows.map((row: AuthMethodRow, index: number) => {
129+ // A row seeded from detection is the spec's own auth declaration:
130+ // it's IMMUTABLE here. We render it read-only (no kind selector, no
131+ // editable fields) so a user can't silently retype the spec's
132+ // method into something nothing backs (e.g. flipping a Bearer-token
133+ // API to OAuth with empty endpoints). The escape hatch is to remove
134+ // the row and add a custom one. Manually added rows (no seed) get
135+ // the full editor.
136+ const detected = row.seeded;
137+ return (
138+ <div
139+ key={index}
140+ className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
141+ >
142+ <div className="flex items-center justify-between">
143+ <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
144+ {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
145+ <span>
146+ Method {index + 1}
147+ {row.seedLabel ? ` · ${row.seedLabel}` : ""}
148+ </span>
149+ </span>
150+ <Button
151+ type="button"
152+ variant="ghost"
153+ size="icon-sm"
154+ aria-label="Remove method"
155+ className="text-muted-foreground hover:text-foreground"
156+ onClick={() => list.removeRowAt(index)}
157+ >
158+ <XIcon />
159+ </Button>
160+ </div>
161+ {detected ? (
162+ <DetectedMethodSummary value={row.value} oauthMetadata={oauthMetadata} />
163+ ) : (
164+ <AuthTemplateEditor
165+ value={row.value}
166+ onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
167+ {...(allowedKinds ? { allowedKinds } : {})}
168+ {...(presets ? { presets } : {})}
169+ {...(oauthMetadata ? { oauthMetadata } : {})}
170+ />
171+ )}
138172 </div>
139− <AuthTemplateEditor
140− value={row.value}
141− onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
142− {...(allowedKinds ? { allowedKinds } : {})}
143− {...(presets ? { presets } : {})}
144− {...(oauthMetadata ? { oauthMetadata } : {})}
145− />
146− </div>
147− ))}
173+ );
174+ })}
148175 </div>
149176 )}
150177 {list.rows.length > 0 && props.footerHint ? (
@@ -153,3 +180,73 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 2 unmodified lines
153180 </section>
154181 );
155182 }
183+
184+/** One read-only `label value` line, mono value, for the detected summary. */
185+function SpecField(props: { readonly label: string; readonly value: string }) {
186+ return (
187+ <div className="flex gap-2 text-xs">
188+ <span className="w-20 shrink-0 text-muted-foreground">{props.label}</span>
189+ <span className="break-all font-mono text-foreground/80">{props.value}</span>
190+ </div>
191+ );
192+}
193+
194+/** Read-only view of a spec-detected method: shows what the spec declared
195+ * (placements / OAuth endpoints) as a DISABLED, non-interactive block. The
196+ * detected method is immutable here, so the summary is styled like a disabled
197+ * field (muted, not-allowed cursor, text not selectable) to communicate that
198+ * plainly. The only action is to remove the row (the header's X) and add a
199+ * custom method to override. */
200+function DetectedMethodSummary(props: {
201+ readonly value: AuthTemplateEditorValue;
202+ readonly oauthMetadata?: "editable" | "discovered";
203+}) {
204+ const { value, oauthMetadata } = props;
205+ // Name the auth kind explicitly: a detection label like MCP's "Detected"
206+ // doesn't say whether it's OAuth or an API key, so surface it here.
207+ const kindLabel =
208+ value.kind === "oauth" ? "OAuth" : value.kind === "apikey" ? "API key" : "No auth";
209+ return (
210+ <div className="space-y-2">
211+ <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
212+ {kindLabel}
213+ </p>
214+ <div
215+ aria-disabled
216+ className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
217+ >
218+ {value.kind === "none" && (
219+ <p className="text-xs">No credential — tools are callable without an account.</p>
220+ )}
221+
222+ {value.kind === "apikey" &&
223+ (value.placements.length > 0 ? (
224+ <div className="flex flex-wrap gap-x-4 gap-y-1">
225+ {value.placements.map((placement, i: number) => (
226+ <PlacementLine key={i} placement={placement} />
227+ ))}
228+ </div>
229+ ) : null)}
230+
231+ {value.kind === "oauth" &&
232+ (oauthMetadata === "discovered" ? (
233+ <p className="text-xs">
234+ OAuth metadata is discovered from this server when you connect an account.
235+ </p>
236+ ) : (
237+ <div className="space-y-1">
238+ {value.authorizationUrl ? (
239+ <SpecField label="Authorize" value={value.authorizationUrl} />
240+ ) : null}
241+ {value.tokenUrl ? <SpecField label="Token" value={value.tokenUrl} /> : null}
242+ {value.scopes.length > 0 ? (
243+ <SpecField label="Scopes" value={value.scopes.join(", ")} />
244+ ) : null}
245+ </div>
246+ ))}
247+ </div>
248+
249+ <p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p>
250+ </div>
251+ );
252+}
packages/react/src/lib/auth-placements.tsx+6−1
@@ -112,8 +112,13 @@ export function PlacementLine(props: { readonly placement: Placement; readonly m · 111 unmodified lines
112112 : placement.carrier === "env"
113113 ? `${placement.name || "TOKEN"}=`
114114 : `?${placement.name || "api_key"}=`;
115+ // Plain inline (not inline-flex): flex trims the whitespace at the edges of
116+ // each child, which would drop the space after "Authorization:" and the
117+ // trailing space carried by a prefix like "Bearer ", rendering
118+ // "Authorization:Bearer••••••". whitespace-pre-wrap keeps those spaces while
119+ // still allowing the line to wrap.
115120 return (
116− <span className="inline-flex items-center font-mono text-xs text-muted-foreground">
121+ <span className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
117122 {lead}
118123 {placement.prefix ? (
119124 <span className="text-muted-foreground/60">{placement.prefix}</span>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t