History

Update CLI web setup flow

Rhys Sullivan committed 4 months ago

Commit 30ecd421 parent 831bfbd7 files · +233 −112Browse files

Unified · Split

7 files
  1. README.md +6 −3
  2. apps/cli/src/main.ts +134 −83
  3. e2e/local/auth.test.ts +10 −8
  4. e2e/local/cli-open-stale-manifest.test.ts +5 −1
  5. e2e/local/cli-web-transition.test.ts +57 −0
  6. e2e/local/local-server.ts +15 −12
  7. e2e/local/mcp-browser-approve.test.ts +6 −5
README.md+6−3
@@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e · 9 unmodified lines
10 10
11```bash11```bash
12npm install -g executor12npm install -g executor
13executor install
13executor web14executor web
14```15```
15 16
16This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools.17This installs the local background service and opens the web UI. From there, add your first source and start using tools.
17 18
18### Use as an MCP server19### Use as an MCP server
19 20
@@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h · 40 unmodified lines
60 61
61### Via the web UI62### Via the web UI
62 63
63Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.64Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
64 65
65### Via the CLI66### Via the CLI
66 67
@@ -119,7 +120,9 @@ executor resume --execution-id exec_123 · 52 unmodified lines
119## CLI reference120## CLI reference
120 121
121```bash122```bash
122executor web # start runtime + web UI123executor install # install/start the durable background service
124executor web # open the running web UI
125executor web --foreground # start a temporary foreground runtime + web UI
123executor daemon run # start persistent local daemon in background126executor daemon run # start persistent local daemon in background
124executor daemon status # show daemon status127executor daemon status # show daemon status
125executor daemon stop # stop daemon128executor daemon stop # stop daemon
apps/cli/src/main.ts+134−83
@@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( · 1915 unmodified lines
1916const webCommand = Command.make(1916const webCommand = Command.make(
1917 "web",1917 "web",
1918 {1918 {
1919 port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)),1919 foreground: Options.boolean("foreground")
1920 .pipe(Options.withDefault(false))
1921 .pipe(
1922 Options.withDescription(
1923 "Run a temporary web server in this terminal. By default, web opens the installed background service.",
1924 ),
1925 ),
1926 port: Options.integer("port")
1927 .pipe(Options.withDefault(DEFAULT_PORT))
1928 .pipe(Options.withDescription("Port for the temporary --foreground server.")),
1920 hostname: Options.string("hostname")1929 hostname: Options.string("hostname")
1921 .pipe(Options.withDefault("127.0.0.1"))1930 .pipe(Options.withDefault("127.0.0.1"))
1922 .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")),1931 .pipe(
1932 Options.withDescription(
1933 "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.",
1934 ),
1935 ),
1923 allowedHost: Options.string("allowed-host")1936 allowedHost: Options.string("allowed-host")
1924 .pipe(Options.atLeast(0))1937 .pipe(Options.atLeast(0))
1925 .pipe(1938 .pipe(
1926 Options.withDescription(1939 Options.withDescription(
1927 "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",1940 "For --foreground, grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
1928 ),1941 ),
1929 ),1942 ),
1930 authToken: Options.string("auth-token")1943 authToken: Options.string("auth-token")
1931 .pipe(Options.optional)1944 .pipe(Options.optional)
1932 .pipe(1945 .pipe(
1933 Options.withDescription(1946 Options.withDescription(
1934 "Override the bearer token. Defaults to the stable token in auth.json.",1947 "For --foreground, override the bearer token. Defaults to the stable token in auth.json.",
1935 ),1948 ),
1936 ),1949 ),
1937 scope,1950 scope,
1938 },1951 },
1939 ({ port, scope, hostname, allowedHost, authToken }) =>1952 ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
1940 Effect.gen(function* () {1953 Effect.gen(function* () {
1954 if (!foreground) {
1955 yield* openRunningLocalWebApp();
1956 return;
1957 }
1941 applyScope(scope);1958 applyScope(scope);
1942 yield* runForegroundSession({1959 yield* runForegroundSession({
1943 port,1960 port,
@@ -1946,7 +1963,7 @@ const webCommand = Command.make( · 2 unmodified lines
1946 authToken: Option.getOrUndefined(authToken),1963 authToken: Option.getOrUndefined(authToken),
1947 });1964 });
1948 }),1965 }),
1949).pipe(Command.withDescription("Start a foreground web session"));1966).pipe(Command.withDescription("Open the Executor web UI"));
1950 1967
1951const daemonRunCommand = Command.make(1968const daemonRunCommand = Command.make(
1952 "run",1969 "run",
@@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( · 155 unmodified lines
2108 2125
2109const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;2126const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2110 2127
2111const serviceInstallCommand = Command.make(2128const servicePortOption = () =>
2112 "install",2129 Options.integer("port")
2113 {2130 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2114 port: Options.integer("port")2131 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
2115 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2116 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
2117 },
2118 ({ port }) =>
2119 Effect.gen(function* () {
2120 if (isDevMode) {
2121 return yield* Effect.fail(
2122 new Error(
2123 [
2124 "`service install` requires the compiled `executor` binary so the OS can run it directly.",
2125 `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2126 ].join("\n"),
2127 ),
2128 );
2129 }
2130
2131 const backend = getServiceBackend();
2132 if (!backend.automated) {
2133 // Unsupported platforms surface their manual steps via the install error.
2134 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2135 return;
2136 }
2137 2132
2138 // Don't fight an already-running local server against the same data dir2133const installService = (port: number, commandName: string) =>
2139 // (a desktop sidecar, a foreground `executor web`, or an existing daemon).2134 Effect.gen(function* () {
2140 const active = yield* readActiveLocalServerManifest();2135 const command = `${cliPrefix} ${commandName}`;
2141 if (active) {2136 if (isDevMode) {
2142 const status = yield* backend.status();2137 return yield* Effect.fail(
2143 if (status.registered && status.running && active.kind === "cli-daemon") {2138 new Error(
2144 console.log(2139 [
2145 `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,2140 `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`,
2146 );2141 `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2147 return;2142 ].join("\n"),
2148 }2143 ),
2149 return yield* Effect.fail(2144 );
2150 new Error(2145 }
2151 [
2152 `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2153 `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2154 ].join("\n"),
2155 ),
2156 );
2157 }
2158 2146
2159 // The unit carries no secret: the supervised daemon mints/loads its bearer2147 const backend = getServiceBackend();
2160 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read2148 if (!backend.automated) {
2161 // the same file — so reachability is the credential-free /api/health probe.2149 // Unsupported platforms surface their manual steps via the install error.
2162 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2150 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2151 return;
2152 }
2163 2153
2164 const origin = supervisedServiceOrigin(port);2154 // Don't fight an already-running local server against the same data dir
2165 const reachable = yield* waitForReachable({2155 // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2166 check: isServerReachable(origin),2156 const active = yield* readActiveLocalServerManifest();
2167 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,2157 if (active) {
2168 intervalMs: DAEMON_BOOT_POLL_MS,2158 const status = yield* backend.status();
2169 });2159 if (status.registered && status.running && active.kind === "cli-daemon") {
2170 if (!reachable) {2160 console.log(
2171 return yield* Effect.fail(2161 `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
2172 new Error(
2173 [
2174 `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2175 `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2176 ].join("\n"),
2177 ),
2178 );2162 );
2163 return;
2179 }2164 }
2165 return yield* Effect.fail(
2166 new Error(
2167 [
2168 `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2169 `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run \`${command}\`.`,
2170 ].join("\n"),
2171 ),
2172 );
2173 }
2180 2174
2181 console.log(`Executor is now running as a background service at ${origin}.`);2175 // The unit carries no secret: the supervised daemon mints/loads its bearer
2182 console.log("It keeps serving after you quit the app and restarts on login.");2176 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2183 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);2177 // the same file — so reachability is the credential-free /api/health probe.
2184 }),2178 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2179
2180 const origin = supervisedServiceOrigin(port);
2181 const reachable = yield* waitForReachable({
2182 check: isServerReachable(origin),
2183 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2184 intervalMs: DAEMON_BOOT_POLL_MS,
2185 });
2186 if (!reachable) {
2187 return yield* Effect.fail(
2188 new Error(
2189 [
2190 `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2191 `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2192 ].join("\n"),
2193 ),
2194 );
2195 }
2196
2197 console.log(`Executor is now running as a background service at ${origin}.`);
2198 console.log("It keeps serving after you quit the app and restarts on login.");
2199 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2200 });
2201
2202const serviceInstallCommand = Command.make(
2203 "install",
2204 {
2205 port: servicePortOption(),
2206 },
2207 ({ port }) => installService(port, "service install"),
2185).pipe(2208).pipe(
2186 Command.withDescription("Install and start Executor as an OS-supervised background service"),2209 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2187);2210);
@@ -2240,6 +2263,16 @@ const serviceCommand = Command.make("service").pipe( · 52 unmodified lines
2240 Command.withDescription("Manage the OS-supervised background service"),2263 Command.withDescription("Manage the OS-supervised background service"),
2241);2264);
2242 2265
2266const installCommand = Command.make(
2267 "install",
2268 {
2269 port: servicePortOption(),
2270 },
2271 ({ port }) => installService(port, "install"),
2272).pipe(
2273 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2274);
2275
2243// ---------------------------------------------------------------------------2276// ---------------------------------------------------------------------------
2244// Root command2277// Root command
2245// ---------------------------------------------------------------------------2278// ---------------------------------------------------------------------------
@@ -2263,17 +2296,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines
2263 execFile(cmd, [...args], () => {});2296 execFile(cmd, [...args], () => {});
2264 });2297 });
2265 2298
2266/**2299const printNoRunningLocalWebApp = (): void => {
2267 * `executor open` — the friendly way back in. Reads the running local server's2300 console.log("Executor is not running.");
2268 * manifest and opens the browser straight to its `?_token=` URL, so the user2301 console.log("");
2269 * never has to copy a bearer token out of a terminal or auth.json by hand.2302 console.log("Install and start the background service:");
2270 */2303 console.log(` ${cliPrefix} install`);
2271const openCommand = Command.make("open", {}, () =>2304 console.log("");
2305 console.log("Then open the web UI:");
2306 console.log(` ${cliPrefix} web`);
2307 console.log("");
2308 console.log("For a temporary foreground server:");
2309 console.log(` ${cliPrefix} web --foreground`);
2310};
2311
2312const openRunningLocalWebApp = (): Effect.Effect<
2313 void,
2314 never,
2315 FileSystem.FileSystem | PlatformPath.Path
2316> =>
2272 Effect.gen(function* () {2317 Effect.gen(function* () {
2273 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));2318 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2274 if (!manifest) {2319 if (!manifest) {
2275 console.log("No local Executor server is running.");2320 printNoRunningLocalWebApp();
2276 console.log(`Start one with: ${cliPrefix} web`);
2277 return;2321 return;
2278 }2322 }
2279 const { origin, auth } = manifest.connection;2323 const { origin, auth } = manifest.connection;
@@ -2281,8 +2325,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line
2281 const url = token ? `${origin}/?_token=${token}` : origin;2325 const url = token ? `${origin}/?_token=${token}` : origin;
2282 console.log(`Opening ${url}`);2326 console.log(`Opening ${url}`);
2283 yield* openInBrowser(url);2327 yield* openInBrowser(url);
2284 }),2328 });
2285).pipe(2329
2330/**
2331 * `executor open` — the friendly way back in. Reads the running local server's
2332 * manifest and opens the browser straight to its `?_token=` URL, so the user
2333 * never has to copy a bearer token out of a terminal or auth.json by hand.
2334 */
2335const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe(
2286 Command.withDescription("Open the running Executor web app in your browser, already signed in"),2336 Command.withDescription("Open the running Executor web app in your browser, already signed in"),
2287);2337);
2288 2338
@@ -2291,6 +2341,7 @@ const root = Command.make("executor").pipe( · 2 unmodified lines
2291 callCommand,2341 callCommand,
2292 resumeCommand,2342 resumeCommand,
2293 toolsCommand,2343 toolsCommand,
2344 installCommand,
2294 serverCommand,2345 serverCommand,
2295 webCommand,2346 webCommand,
2296 daemonCommand,2347 daemonCommand,
e2e/local/auth.test.ts+10−8
@@ -1,16 +1,18 @@
1// Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way1// Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way
2// a human tests it: run the dev CLI in a real terminal, watch `executor web`2// a human tests it: run the dev CLI in a real terminal, watch
3// print its one-time `?_token=` URL, then drive a browser against it. Two clean3// `executor web --foreground` print its one-time `?_token=` URL, then drive a
4// stories, each its own film (terminal.cast + session.mp4 spliced by4// browser against it. Two clean stories, each its own film (terminal.cast +
5// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`):5// session.mp4 spliced by scenario.ts), each booting its OWN temporary server
6// (own data dir, `--port 0`):
6//7//
7// 1. The CLI's ?_token URL boots straight into an authenticated console.8// 1. The CLI's ?_token URL boots straight into an authenticated console.
8// 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the9// 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the
9// token connects.10// token connects.
10//11//
11// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal12// `withLocalServer` (shared helper) runs `executor web --foreground` in a
12// and hands the printed URL to a body; the terminal stays up until the body is13// recorded terminal and hands the printed URL to a body; the terminal stays up
13// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes.14// until the body is done, then Ctrl-C shuts it (and its vite child) down so the
15// PTY closes.
14import { expect } from "@effect/vitest";16import { expect } from "@effect/vitest";
15import { Effect } from "effect";17import { Effect } from "effect";
16 18
@@ -30,7 +32,7 @@ scenario( · 13 unmodified lines
30 32
31 yield* withLocalServer(cli, runDir, ({ url, token }) =>33 yield* withLocalServer(cli, runDir, ({ url, token }) =>
32 browser.session(identity, async ({ page, step }) => {34 browser.session(identity, async ({ page, step }) => {
33 await step("Open the ?_token URL printed by executor web", async () => {35 await step("Open the ?_token URL printed by executor web --foreground", async () => {
34 await page.goto(url, { waitUntil: "domcontentloaded" });36 await page.goto(url, { waitUntil: "domcontentloaded" });
35 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });37 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
36 // Integrations actually LOAD (the built-in Executor source) — proves38 // Integrations actually LOAD (the built-in Executor source) — proves
e2e/local/cli-open-stale-manifest.test.ts+5−1
@@ -77,7 +77,11 @@ scenario( · 76 unmodified lines
77 });77 });
78 const output = `${stdout}\n${stderr}`;78 const output = `${stdout}\n${stderr}`;
79 expect(output, "stale endpoint should be rejected before printing the token URL").toContain(79 expect(output, "stale endpoint should be rejected before printing the token URL").toContain(
80 "No local Executor server is running.",80 "Executor is not running.",
81 );
82 expect(output, "the recovery path should point users at durable setup").toContain("install");
83 expect(output, "the old foreground behavior should remain discoverable").toContain(
84 "web --foreground",
81 );85 );
82 expect(output, "the stale bearer must not be printed").not.toContain(token);86 expect(output, "the stale bearer must not be printed").not.toContain(token);
83 expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`);87 expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`);
e2e/local/cli-web-transition.test.ts+57−0
@@ -0,0 +1,57 @@
1// Local CLI: `executor web` used to mean "start a foreground server". The
2// first-time CLI setup path is now explicit: install the durable background
3// service first, then use `executor web` to open it. A fresh `web` invocation
4// should guide the user without minting local-server credentials/manifest state
5// or binding ports.
6import { execFile } from "node:child_process";
7import { existsSync, mkdtempSync, rmSync } from "node:fs";
8import { tmpdir } from "node:os";
9import { join } from "node:path";
10import { fileURLToPath } from "node:url";
11import { promisify } from "node:util";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15
16import { scenario } from "../src/scenario";
17
18const execFileAsync = promisify(execFile);
19const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
20
21scenario(
22 "CLI web · a fresh install points users at setup instead of starting a foreground server",
23 { timeout: 120_000 },
24 Effect.promise(async () => {
25 const root = mkdtempSync(join(tmpdir(), "executor-web-transition-"));
26 const dataDir = join(root, "data");
27 try {
28 const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "web"], {
29 cwd: repoRoot,
30 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
31 });
32 const output = `${stdout}\n${stderr}`;
33
34 expect(output, "plain web should not start the old foreground server").not.toContain("Open:");
35 expect(output, "plain web should explain that no service is running").toContain(
36 "Executor is not running.",
37 );
38 expect(output, "plain web should direct first-time users to durable setup").toContain(
39 "install",
40 );
41 expect(
42 output,
43 "plain web should keep the temporary-server escape hatch discoverable",
44 ).toContain("web --foreground");
45 expect(
46 existsSync(join(dataDir, "server-control", "auth.json")),
47 "plain web should not mint a local auth token",
48 ).toBe(false);
49 expect(
50 existsSync(join(dataDir, "server-control", "server.json")),
51 "plain web should not write a foreground server manifest",
52 ).toBe(false);
53 } finally {
54 rmSync(root, { recursive: true, force: true });
55 }
56 }),
57);
e2e/local/local-server.ts+15−12
@@ -1,9 +1,9 @@
1// Shared helper for the `local` e2e project: boot a real `executor web` in a1// Shared helper for the `local` e2e project: boot a real temporary server with
2// recorded terminal, parse its printed one-time `?_token=` URL, and run a body2// `executor web --foreground` in a recorded terminal, parse its printed one-time
3// against it. Each scenario boots its OWN server (own throwaway data dir,3// `?_token=` URL, and run a body against it. Each scenario boots its OWN server
4// `--port 0`) so files can run in parallel without colliding. The terminal4// (own throwaway data dir, `--port 0`) so files can run in parallel without
5// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so5// colliding. The terminal stays up until the body settles, then Ctrl-C gives a
6// the vite child dies and the PTY closes).6// graceful shutdown (so the vite child dies and the PTY closes).
7import { mkdtempSync, rmSync } from "node:fs";7import { mkdtempSync, rmSync } from "node:fs";
8import { tmpdir } from "node:os";8import { tmpdir } from "node:os";
9import { join } from "node:path";9import { join } from "node:path";
@@ -29,10 +29,11 @@ export interface ServerHandle { · 19 unmodified lines
29}29}
30 30
31/**31/**
32 * Boot `executor web` and run `body` against the resulting {@link ServerHandle}.32 * Boot `executor web --foreground` and run `body` against the resulting
33 * Keeps the server up until the body settles, then Ctrl-C for a graceful33 * {@link ServerHandle}. Keeps the server up until the body settles, then Ctrl-C
34 * shutdown. Cleans up the throwaway data dir. The body may drive the browser,34 * for a graceful shutdown. Cleans up the throwaway data dir. The body may drive
35 * a typed API client, an MCP client — anything that needs the live server.35 * the browser, a typed API client, an MCP client — anything that needs the live
36 * server.
36 */37 */
37export const withLocalServer = (38export const withLocalServer = (
38 cli: CliSurface,39 cli: CliSurface,
@@ -54,7 +55,7 @@ export const withLocalServer = ( · 15 unmodified lines
54 yield* Effect.all(55 yield* Effect.all(
55 [56 [
56 cli.session(57 cli.session(
57 ["bun", "run", "dev:cli", "web", "--port", "0"],58 ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"],
58 async (term) => {59 async (term) => {
59 markRecordingStart(runDir, "terminal");60 markRecordingStart(runDir, "terminal");
60 markFocus(runDir, "terminal");61 markFocus(runDir, "terminal");
@@ -64,7 +65,9 @@ export const withLocalServer = ( · 3 unmodified lines
64 );65 );
65 const url = TOKEN_URL.exec(snapshot.text)?.[0];66 const url = TOKEN_URL.exec(snapshot.text)?.[0];
66 if (!url) {67 if (!url) {
67 throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`);68 throw new Error(
69 `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`,
70 );
68 }71 }
69 publishUrl(url);72 publishUrl(url);
70 await bodyDone;73 await bodyDone;
e2e/local/mcp-browser-approve.test.ts+6−5
@@ -5,11 +5,12 @@ · 4 unmodified lines
5// the MCP `resume` tool (auth on the API path), so it never drives the browser5// the MCP `resume` tool (auth on the API path), so it never drives the browser
6// page and could not catch this. This drives the real page in a real browser.6// page and could not catch this. This drives the real page in a real browser.
7//7//
8// Flow: boot `executor web` → create a require_approval policy on a built-in8// Flow: boot `executor web --foreground` → create a require_approval policy on a
9// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser9// built-in tool → an MCP client (bearer) executes that tool with
10// → the server returns a paused `approvalUrl` → open it in the browser (with the10// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it
11// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a11// in the browser (with the `?_token` bootstrap) → click Approve → the MCP
12// negative: the approval endpoint 401s without the bearer.12// `resume` call completes. Plus a negative: the approval endpoint 401s without
13// the bearer.
13import { expect } from "@effect/vitest";14import { expect } from "@effect/vitest";
14import { Effect } from "effect";15import { Effect } from "effect";
15import { HttpApiClient } from "effect/unstable/httpapi";16import { HttpApiClient } from "effect/unstable/httpapi";