History

fix supervised and local regressions

Rhys Sullivan committed 5 months ago

Commit 831bfbd1 parent 498e4a614 files · +997 −52Browse files

Unified · Split

14 files
  1. apps/cli/src/daemon.ts +2 −2
  2. apps/cli/src/main.ts +2 −2
  3. apps/cli/src/service.ts +18 −5
  4. apps/desktop/src/main/index.ts +15 −4
  5. apps/desktop/src/main/sidecar.ts +9 −11
  6. apps/desktop/src/sidecar/server.ts +3 −1
  7. apps/host-selfhost/src/mcp/index.ts +35 −6
  8. apps/local/vite.config.ts +31 −13
  9. e2e/desktop-packaged/supervised-regressions.test.ts +349 −0
  10. e2e/local/cli-open-stale-manifest.test.ts +89 −0
  11. e2e/local/cli-systemd-unit-escaping.test.ts +44 −0
  12. e2e/local/vite-dev-routing.test.ts +228 −0
  13. e2e/selfhost/mcp-browser-approval-ownership.test.ts +138 −0
  14. packages/hosts/mcp/src/in-memory-session-store.ts +34 −8
apps/cli/src/daemon.ts+2−2
@@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( · 88 unmodified lines
89 // misconfigured base URL can't leak the bearer token to a third-party host.89 // misconfigured base URL can't leak the bearer token to a third-party host.
90 const url = new URL("/api/health", input.baseUrl);90 const url = new URL("/api/health", input.baseUrl);
91 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });91 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
92 await response.body?.cancel();92 const body = await response.text();
93 return response.ok;93 return response.ok && body.trim() === "ok";
94 }).pipe(Effect.catchCause(() => Effect.succeed(false)));94 }).pipe(Effect.catchCause(() => Effect.succeed(false)));
95 95
96// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
apps/cli/src/main.ts+2−2
@@ -2270,8 +2270,8 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 2269 unmodified lines
2270 */2270 */
2271const openCommand = Command.make("open", {}, () =>2271const openCommand = Command.make("open", {}, () =>
2272 Effect.gen(function* () {2272 Effect.gen(function* () {
2273 const manifest = yield* readLocalServerManifest();2273 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2274 if (!manifest || !isPidAlive(manifest.pid)) {2274 if (!manifest) {
2275 console.log("No local Executor server is running.");2275 console.log("No local Executor server is running.");
2276 console.log(`Start one with: ${cliPrefix} web`);2276 console.log(`Start one with: ${cliPrefix} web`);
2277 return;2277 return;
apps/cli/src/service.ts+18−5
@@ -340,11 +340,24 @@ export interface SystemdUnitOptions { · 339 unmodified lines
340 readonly stderrPath: string;340 readonly stderrPath: string;
341}341}
342 342
343const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
344
345const systemdQuote = (value: string): string => {
346 if (SYSTEMD_BARE_VALUE.test(value)) return value;
347 const escaped = value
348 .replaceAll("\\", "\\\\")
349 .replaceAll('"', '\\"')
350 .replaceAll("\n", "\\n")
351 .replaceAll("\r", "\\r")
352 .replaceAll("\t", "\\t");
353 return `"${escaped}"`;
354};
355
343/** Render a systemd --user unit. Pure (snapshot-tested). */356/** Render a systemd --user unit. Pure (snapshot-tested). */
344export const generateSystemdUnit = (options: SystemdUnitOptions): string => {357export const generateSystemdUnit = (options: SystemdUnitOptions): string => {
345 const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");358 const execStart = options.execStart.map(systemdQuote).join(" ");
346 const env = Object.entries(options.environment)359 const env = Object.entries(options.environment)
347 .map(([key, value]) => `Environment=${key}=${value}`)360 .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
348 .join("\n");361 .join("\n");
349 return `[Unit]362 return `[Unit]
350Description=Executor supervised daemon363Description=Executor supervised daemon
@@ -354,9 +367,9 @@ After=default.target · 3 unmodified lines
354Type=simple367Type=simple
355ExecStart=${execStart}368ExecStart=${execStart}
356${env}369${env}
357WorkingDirectory=${options.workingDirectory}370WorkingDirectory=${systemdQuote(options.workingDirectory)}
358StandardOutput=append:${options.stdoutPath}371StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)}
359StandardError=append:${options.stderrPath}372StandardError=${systemdQuote(`append:${options.stderrPath}`)}
360Restart=on-failure373Restart=on-failure
361RestartSec=5s374RestartSec=5s
362 375
apps/desktop/src/main/index.ts+15−4
@@ -415,12 +415,23 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 414 unmodified lines
415};415};
416 416
417const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {417const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {
418 // A supervised daemon isn't ours to restart — just reload the window against418 // A supervised daemon owns its own process lifetime. Re-installing the unit
419 // the same endpoint instead of tearing down a process we don't own.419 // rewrites settings such as the configured port, then launchd restarts it.
420 if (connection?.supervisedDaemon) {420 if (connection?.supervisedDaemon) {
421 await installSupervisedService({
422 port: getServerSettings().port,
423 dataDir: DESKTOP_DATA_DIR,
424 });
425 const next = await waitForSupervisedAttach(30_000);
426 if (!next) {
427 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call
428 throw new Error("Supervised daemon failed to restart — see Settings");
429 }
430 connection = next;
431 installBearerAuthHeader(next.baseUrl, next.authToken);
421 const window = liveMainWindow();432 const window = liveMainWindow();
422 if (window) await window.loadURL(connection.baseUrl);433 if (window) await window.loadURL(next.baseUrl);
423 return toDesktopServerConnection(connection);434 return toDesktopServerConnection(next);
424 }435 }
425 if (connection) {436 if (connection) {
426 await stopConnection(connection);437 await stopConnection(connection);
apps/desktop/src/main/sidecar.ts+9−11
@@ -408,20 +408,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 407 unmodified lines
408 });408 });
409}409}
410 410
411/**411/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
412 * Probe whether an HTTP server is listening at `origin`. Any HTTP response —412const isDaemonReachable = async (origin: string): Promise<boolean> => {
413 * even 401/404 — means a server is up; only a network error or timeout counts
414 * as unreachable. Auth is attached so a 401 still confirms liveness cleanly.
415 */
416const isDaemonReachable = async (origin: string, authToken: string): Promise<boolean> => {
417 const controller = new AbortController();413 const controller = new AbortController();
418 const timer = setTimeout(() => controller.abort(), 1500);414 const timer = setTimeout(() => controller.abort(), 1500);
419 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal415 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal
420 try {416 try {
421 const headers: Record<string, string> = {};417 const response = await fetch(new URL("/api/health", origin), {
422 if (authToken) headers.Authorization = `Bearer ${authToken}`;418 signal: controller.signal,
423 await fetch(origin, { signal: controller.signal, headers, redirect: "manual" });419 redirect: "manual",
424 return true;420 });
421 const body = await response.text();
422 return response.ok && body.trim() === "ok";
425 } catch {423 } catch {
426 return false;424 return false;
427 } finally {425 } finally {
@@ -452,7 +450,7 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 24 unmodified lines
452 const origin = manifest.connection.origin;450 const origin = manifest.connection.origin;
453 const auth = manifest.connection.auth;451 const auth = manifest.connection.auth;
454 const authToken = auth && auth.kind === "bearer" ? auth.token : "";452 const authToken = auth && auth.kind === "bearer" ? auth.token : "";
455 if (!(await isDaemonReachable(origin, authToken))) return null;453 if (!(await isDaemonReachable(origin))) return null;
456 454
457 const url = new URL(origin);455 const url = new URL(origin);
458 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);456 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);
apps/desktop/src/sidecar/server.ts+3−1
@@ -63,7 +63,7 @@ if (sentryDsn) { · 62 unmodified lines
63 });63 });
64}64}
65 65
66import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";66import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
67import { homedir } from "node:os";67import { homedir } from "node:os";
68import {68import {
69 normalizeExecutorServerConnection,69 normalizeExecutorServerConnection,
@@ -116,7 +116,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines
116 executablePath: process.execPath || null,116 executablePath: process.execPath || null,
117 },117 },
118 }),118 }),
119 { mode: 0o600 },
119 );120 );
121 chmodSync(manifestPath, 0o600);
120};122};
121 123
122const removeOwnManifest = () => {124const removeOwnManifest = () => {
apps/host-selfhost/src/mcp/index.ts+35−6
@@ -1,7 +1,12 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { IdentityProvider } from "@executor-js/api/server";3import { IdentityProvider } from "@executor-js/api/server";
4import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";4import type {
5 McpAuthProvider,
6 McpErrorReporter,
7 McpSessionStore,
8 Principal,
9} from "@executor-js/host-mcp";
5 10
6import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";11import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
7import type { SelfHostDbHandle } from "../db/self-host-db";12import type { SelfHostDbHandle } from "../db/self-host-db";
@@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines
64const jsonResponse = (value: unknown, status: number): Response =>69const jsonResponse = (value: unknown, status: number): Response =>
65 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });70 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
66 71
72const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
73 (role ?? "user")
74 .split(",")
75 .map((r) => r.trim())
76 .filter((r) => r.length > 0);
77
78type BetterAuthSession = NonNullable<
79 Awaited<ReturnType<BetterAuthHandle["auth"]["api"]["getSession"]>>
80>;
81
82const principalFromSession = (
83 resolved: BetterAuthSession,
84 betterAuth: BetterAuthHandle,
85): Principal => ({
86 accountId: resolved.user.id,
87 organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId,
88 organizationName: betterAuth.organizationName,
89 email: resolved.user.email,
90 name: resolved.user.name ?? null,
91 avatarUrl: resolved.user.image ?? null,
92 roles: parseRoles(resolved.user.role ?? null),
93});
94
67/**95/**
68 * Gate the browser-approval endpoints behind a valid Better Auth session (the96 * Gate the browser-approval endpoints behind a valid Better Auth session (the
69 * console page calls them with the user's cookie), then delegate to the97 * console page calls them with the user's cookie), then delegate to the
70 * in-process store's paused/resume handlers. Single-tenant: any authenticated98 * in-process store's paused/resume handlers with the resolved principal so the
71 * user of the one org may act on a session it still holds — the store confirms99 * store can enforce MCP session ownership before exposing or recording a
72 * the execution belongs to the addressed session before recording.100 * browser-approval decision.
73 */101 */
74const makeApprovalHandler =102const makeApprovalHandler =
75 (103 (
@@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines
85 }).pipe(Effect.orElseSucceed(() => null)),113 }).pipe(Effect.orElseSucceed(() => null)),
86 );114 );
87 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);115 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);
116 const principal = principalFromSession(session, betterAuth);
88 117
89 return (118 return (
90 (await store.handlePausedRequest(request)) ??119 (await store.handlePausedRequest(request, principal)) ??
91 (await store.handleApprovalRequest(request)) ??120 (await store.handleApprovalRequest(request, principal)) ??
92 jsonResponse({ error: "Not found" }, 404)121 jsonResponse({ error: "Not found" }, 404)
93 );122 );
94 };123 };
apps/local/vite.config.ts+31−13
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
5import { defineConfig, type Plugin } from "vite";5import { defineConfig, type Plugin } from "vite";
6import appPlugin from "@executor-js/app/vite";6import appPlugin from "@executor-js/app/vite";
7import { loadOrMintLocalAuthToken } from "./src/auth";7import { loadOrMintLocalAuthToken } from "./src/auth";
8import { consumeOAuthResult } from "./src/oauth-result-store";
8import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";9import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
9 10
10// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json11// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
@@ -114,20 +115,37 @@ function executorApiPlugin(): Plugin { · 103 unmodified lines
114 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);115 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
115 }116 }
116 117
117 // Strip /api prefix for Effect handlers
118 const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl;
119
120 const hasBody = req.method !== "GET" && req.method !== "HEAD";118 const hasBody = req.method !== "GET" && req.method !== "HEAD";
121 const webRequest = new Request(new URL(url, origin), {119 const webRequest = (url: string): Request =>
122 method: req.method,120 new Request(new URL(url, origin), {
123 headers,121 method: req.method,
124 body: hasBody ? Readable.toWeb(req) : undefined,122 headers,
125 duplex: hasBody ? "half" : undefined,123 body: hasBody ? Readable.toWeb(req) : undefined,
126 } as RequestInit);124 duplex: hasBody ? "half" : undefined,
127 125 } as RequestInit);
128 const response = isMcp126
129 ? await handlers.mcp.handleRequest(webRequest)127 let response: Response;
130 : await handlers.api.handler(webRequest);128 if (isMcp) {
129 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130 } else if (pathOnly === "/api/health" && req.method === "GET") {
131 response = new Response("ok", { headers: { "content-type": "text/plain" } });
132 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
133 const handler =
134 req.method === "GET"
135 ? handlers.mcp.handlePausedRequest
136 : handlers.mcp.handleApprovalRequest;
137 response = await handler(webRequest(rawUrl));
138 } else {
139 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);
140 if (awaitMatch && req.method === "GET") {
141 response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {
142 headers: { "content-type": "application/json" },
143 });
144 } else {
145 // Strip /api prefix for Effect handlers.
146 response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/"));
147 }
148 }
131 149
132 res.statusCode = response.status;150 res.statusCode = response.status;
133 response.headers.forEach((v, k) => res.setHeader(k, v));151 response.headers.forEach((v, k) => res.setHeader(k, v));
e2e/desktop-packaged/supervised-regressions.test.ts+349−0
@@ -0,0 +1,349 @@
1// Packaged desktop supervised-daemon regressions. These run against the real
2// electron-builder bundle and its compiled sidecar because the supervised attach
3// path is production-only (`app.isPackaged`).
4import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process";
5import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
6import { createServer, type IncomingMessage } from "node:http";
7import net from "node:net";
8import { tmpdir } from "node:os";
9import { dirname, join } from "node:path";
10import { promisify } from "node:util";
11
12import { expect, it } from "@effect/vitest";
13import { Effect } from "effect";
14import { _electron, type ElectronApplication } from "playwright";
15import {
16 normalizeExecutorServerConnection,
17 serializeExecutorLocalServerManifest,
18} from "@executor-js/sdk/shared";
19
20import { scenario } from "../src/scenario";
21import { RunDir } from "../src/services";
22import { waitForHttp } from "../setup/boot";
23
24interface PackagedExecutorBridge {
25 readonly getSettings: () => Promise<{ readonly port: number }>;
26 readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>;
27 readonly restartServer: () => Promise<unknown>;
28 readonly getServerConnection: () => Promise<{ readonly origin: string } | null>;
29}
30
31declare global {
32 interface Window {
33 readonly executor: PackagedExecutorBridge;
34 }
35}
36
37const appExe = process.env.E2E_DESKTOP_APP_EXE;
38const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN;
39const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : "";
40
41const guiAvailable = (): boolean => {
42 if (process.platform === "darwin") {
43 try {
44 return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua";
45 } catch {
46 return false;
47 }
48 }
49 if (process.platform === "linux")
50 return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
51 return true;
52};
53
54const packagedSingleInstanceAvailable = (): boolean => {
55 if (process.platform !== "darwin" || !appExe) return true;
56 try {
57 const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], {
58 encoding: "utf8",
59 })
60 .split("\n")
61 .filter(Boolean);
62 return !lines.some((line) => !line.includes(appExe));
63 } catch {
64 return true;
65 }
66};
67
68const requireBundle = (): { readonly app: string; readonly sidecar: string } => {
69 if (!appExe || !sidecarBin) {
70 throw new Error(
71 "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?",
72 );
73 }
74 return { app: appExe, sidecar: sidecarBin };
75};
76
77const freePort = (): Promise<number> =>
78 new Promise((resolve, reject) => {
79 const srv = net.createServer();
80 srv.on("error", reject);
81 srv.listen(0, "127.0.0.1", () => {
82 const port = (srv.address() as net.AddressInfo).port;
83 srv.close(() => resolve(port));
84 });
85 });
86
87interface DaemonStart {
88 readonly child: ChildProcess;
89 readonly ready: boolean;
90 readonly stderr: string;
91}
92
93const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> =>
94 new Promise((resolve) => {
95 const { sidecar } = requireBundle();
96 const child = spawn(sidecar, [], { env, stdio: ["ignore", "pipe", "pipe"] });
97 let stderr = "";
98 let settled = false;
99 const settle = (ready: boolean) => {
100 if (settled) return;
101 settled = true;
102 resolve({ child, ready, stderr });
103 };
104 const timer = setTimeout(() => settle(false), 60_000);
105 child.stdout.on("data", (chunk: Buffer) => {
106 if (chunk.toString().includes("EXECUTOR_READY:")) {
107 clearTimeout(timer);
108 settle(true);
109 }
110 });
111 child.stderr.on("data", (chunk: Buffer) => {
112 stderr += chunk.toString();
113 });
114 child.on("exit", () => {
115 clearTimeout(timer);
116 settle(false);
117 });
118 });
119
120const closeWithVideo = async (
121 app: ElectronApplication | undefined,
122 runDir: string,
123 videoTmp: string,
124) => {
125 const page = app?.windows()[0];
126 const video = page?.video();
127 await app?.close().catch(() => {});
128 const recordedPath = await video?.path().catch(() => undefined);
129 if (recordedPath) {
130 await promisify(execFile)("ffmpeg", [
131 "-y",
132 "-i",
133 recordedPath,
134 "-c:v",
135 "libx264",
136 "-preset",
137 "veryfast",
138 "-crf",
139 "26",
140 "-pix_fmt",
141 "yuv420p",
142 "-movflags",
143 "+faststart",
144 join(runDir, "session.mp4"),
145 ]).catch(() => {});
146 }
147 rmSync(videoTmp, { recursive: true, force: true });
148};
149
150scenario(
151 "Desktop packaged supervised daemon · server manifest is owner-only",
152 { timeout: 180_000 },
153 Effect.promise(async () => {
154 requireBundle();
155 const home = mkdtempSync(join(tmpdir(), "executor-pkg-manifest-mode-"));
156 const dataDir = join(home, ".executor");
157 const manifestPath = join(dataDir, "server-control", "server.json");
158 const port = await freePort();
159 let daemon: ChildProcess | undefined;
160 const previousUmask = process.umask(0o022);
161 try {
162 const started = await startSupervisedDaemon({
163 ...process.env,
164 HOME: home,
165 EXECUTOR_SUPERVISED: "1",
166 EXECUTOR_DATA_DIR: dataDir,
167 EXECUTOR_PORT: String(port),
168 EXECUTOR_HOST: "127.0.0.1",
169 EXECUTOR_AUTH_TOKEN: "manifest-mode-token",
170 EXECUTOR_CLIENT_DIR: clientDir,
171 });
172 daemon = started.child;
173 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(
174 true,
175 );
176 await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });
177
178 const mode = statSync(manifestPath).mode & 0o777;
179 expect(
180 mode.toString(8).padStart(3, "0"),
181 "server.json embeds the bearer and must be owner read/write only",
182 ).toBe("600");
183 } finally {
184 process.umask(previousUmask);
185 daemon?.kill("SIGTERM");
186 rmSync(home, { recursive: true, force: true });
187 }
188 }),
189);
190
191if (!guiAvailable() || !packagedSingleInstanceAvailable()) {
192 it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {});
193} else {
194 scenario(
195 "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer",
196 { timeout: 240_000 },
197 Effect.gen(function* () {
198 const runDir = yield* RunDir;
199 yield* Effect.promise(() => runStaleManifestProbe(runDir));
200 }),
201 );
202
203 scenario(
204 "Desktop packaged supervised settings · changing the port moves the active daemon",
205 { timeout: 300_000 },
206 Effect.gen(function* () {
207 const runDir = yield* RunDir;
208 yield* Effect.promise(() => runSupervisedPortSetting(runDir));
209 }),
210 );
211}
212
213const launchPackaged = (home: string, videoTmp: string): Promise<ElectronApplication> => {
214 const { app } = requireBundle();
215 return _electron.launch({
216 executablePath: app,
217 env: { ...process.env, HOME: home },
218 recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } },
219 timeout: 120_000,
220 });
221};
222
223const runStaleManifestProbe = async (runDir: string) => {
224 const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-"));
225 const dataDir = join(home, ".executor");
226 const controlDir = join(dataDir, "server-control");
227 const videoTmp = join(runDir, ".video-tmp");
228 const token = "stale-manifest-leaked-token";
229 const requests: Array<{ readonly url: string; readonly authorization: string | null }> = [];
230 let resolveFirst!: () => void;
231 const firstRequest = new Promise<void>((resolve) => {
232 resolveFirst = resolve;
233 });
234 const server = createServer((req: IncomingMessage, res) => {
235 requests.push({
236 url: req.url ?? "/",
237 authorization: req.headers.authorization ?? null,
238 });
239 resolveFirst();
240 res.writeHead(200, { "content-type": "text/html" });
241 res.end("<!doctype html><title>fake daemon</title><body>fake daemon</body>");
242 });
243 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
244 const port = (server.address() as net.AddressInfo).port;
245 let app: ElectronApplication | undefined;
246
247 try {
248 mkdirSync(controlDir, { recursive: true });
249 writeFileSync(
250 join(controlDir, "server.json"),
251 serializeExecutorLocalServerManifest({
252 version: 1,
253 kind: "cli-daemon",
254 pid: process.pid,
255 startedAt: new Date().toISOString(),
256 dataDir,
257 scopeDir: dataDir,
258 connection: normalizeExecutorServerConnection({
259 origin: `http://127.0.0.1:${port}`,
260 displayName: "Stale daemon",
261 auth: { kind: "bearer", token },
262 }),
263 owner: { client: "cli", version: null, executablePath: null },
264 }),
265 { mode: 0o600 },
266 );
267
268 app = await launchPackaged(home, videoTmp);
269 const probed = await Promise.race([
270 firstRequest.then(() => true),
271 new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)),
272 ]);
273 expect(probed, "packaged app probed the stale manifest endpoint").toBe(true);
274
275 expect(
276 requests[0]?.authorization ?? null,
277 "the stale-manifest reachability probe must not disclose the saved bearer",
278 ).toBeNull();
279 } finally {
280 await closeWithVideo(app, runDir, videoTmp);
281 await new Promise<void>((resolve) => server.close(() => resolve()));
282 rmSync(home, { recursive: true, force: true });
283 }
284};
285
286const runSupervisedPortSetting = async (runDir: string) => {
287 const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-"));
288 const dataDir = join(home, ".executor");
289 const videoTmp = join(runDir, ".video-tmp");
290 const oldPort = await freePort();
291 const newPort = await freePort();
292 let daemon: ChildProcess | undefined;
293 let app: ElectronApplication | undefined;
294
295 try {
296 const started = await startSupervisedDaemon({
297 ...process.env,
298 HOME: home,
299 EXECUTOR_SUPERVISED: "1",
300 EXECUTOR_DATA_DIR: dataDir,
301 EXECUTOR_PORT: String(oldPort),
302 EXECUTOR_HOST: "127.0.0.1",
303 EXECUTOR_AUTH_TOKEN: "port-setting-token",
304 EXECUTOR_CLIENT_DIR: clientDir,
305 });
306 daemon = started.child;
307 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
308 await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 });
309
310 app = await launchPackaged(home, videoTmp);
311 const page = await app.firstWindow({ timeout: 120_000 });
312 await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
313
314 const before = await page.evaluate(async () => {
315 return window.executor.getServerConnection();
316 });
317 expect(new URL(before!.origin).port, "test starts attached to the original port").toBe(
318 String(oldPort),
319 );
320
321 await page.evaluate(async (port) => {
322 await window.executor.updateSettings({ port });
323 }, newPort);
324
325 await page
326 .evaluate(async () => {
327 await window.executor.restartServer();
328 })
329 .catch(() => undefined);
330 await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
331
332 const after = await page.evaluate(async () => {
333 return {
334 settings: await window.executor.getSettings(),
335 connection: await window.executor.getServerConnection(),
336 };
337 });
338
339 expect(after.settings.port, "the setting was persisted").toBe(newPort);
340 expect(
341 new URL(after.connection!.origin).port,
342 "after restart, the active supervised daemon should be serving on the saved port",
343 ).toBe(String(newPort));
344 } finally {
345 await closeWithVideo(app, runDir, videoTmp);
346 daemon?.kill("SIGTERM");
347 rmSync(home, { recursive: true, force: true });
348 }
349};
e2e/local/cli-open-stale-manifest.test.ts+89−0
@@ -0,0 +1,89 @@
1// Local CLI: `executor open` must not trust a live pid in server.json until it
2// has proven the recorded endpoint is actually the running Executor server.
3import { execFile } from "node:child_process";
4import { randomBytes } from "node:crypto";
5import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6import net from "node:net";
7import { tmpdir } from "node:os";
8import { delimiter, join } from "node:path";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import {
15 normalizeExecutorServerConnection,
16 serializeExecutorLocalServerManifest,
17} from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20
21const execFileAsync = promisify(execFile);
22const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
23
24const freePort = (): Promise<number> =>
25 new Promise((resolve, reject) => {
26 const srv = net.createServer();
27 srv.on("error", reject);
28 srv.listen(0, "127.0.0.1", () => {
29 const port = (srv.address() as net.AddressInfo).port;
30 srv.close(() => resolve(port));
31 });
32 });
33
34const openerName = (): string =>
35 process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
36
37scenario(
38 "CLI open · a stale manifest cannot print or open the saved bearer URL",
39 { timeout: 120_000 },
40 Effect.promise(async () => {
41 const dataDir = mkdtempSync(join(tmpdir(), "executor-open-stale-"));
42 const openerDir = mkdtempSync(join(tmpdir(), "executor-open-shim-"));
43 try {
44 const port = await freePort();
45 const token = `stale-token-${randomBytes(4).toString("hex")}`;
46 mkdirSync(join(dataDir, "server-control"), { recursive: true });
47 writeFileSync(
48 join(dataDir, "server-control", "server.json"),
49 serializeExecutorLocalServerManifest({
50 version: 1,
51 kind: "foreground",
52 pid: process.pid,
53 startedAt: new Date().toISOString(),
54 dataDir,
55 scopeDir: dataDir,
56 connection: normalizeExecutorServerConnection({
57 origin: `http://127.0.0.1:${port}`,
58 displayName: "Stale test server",
59 auth: { kind: "bearer", token },
60 }),
61 owner: { client: "cli", version: null, executablePath: null },
62 }),
63 { mode: 0o600 },
64 );
65
66 const openerPath = join(openerDir, openerName());
67 writeFileSync(openerPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
68 chmodSync(openerPath, 0o755);
69
70 const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "open"], {
71 cwd: repoRoot,
72 env: {
73 ...process.env,
74 EXECUTOR_DATA_DIR: dataDir,
75 PATH: `${openerDir}${delimiter}${process.env.PATH ?? ""}`,
76 },
77 });
78 const output = `${stdout}\n${stderr}`;
79 expect(output, "stale endpoint should be rejected before printing the token URL").toContain(
80 "No local Executor server is running.",
81 );
82 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}`);
84 } finally {
85 rmSync(openerDir, { recursive: true, force: true });
86 rmSync(dataDir, { recursive: true, force: true });
87 }
88 }),
89);
e2e/local/cli-systemd-unit-escaping.test.ts+44−0
@@ -0,0 +1,44 @@
1// Artifact-level e2e for the Linux service install surface: the emitted
2// systemd unit must not contain raw, unescaped paths or environment values
3// whose spaces/quotes change systemd tokenization at boot.
4import { expect } from "@effect/vitest";
5import { Effect } from "effect";
6
7import { generateSystemdUnit } from "../../apps/cli/src/service";
8import { scenario } from "../src/scenario";
9
10scenario(
11 "CLI service · generated systemd units escape paths and environment values",
12 {},
13 Effect.sync(() => {
14 const unit = generateSystemdUnit({
15 execStart: [
16 '/home/alice/Executor "Beta"/executor',
17 "daemon",
18 "run",
19 "--foreground",
20 "--port",
21 "4789",
22 ],
23 environment: {
24 EXECUTOR_SUPERVISED: "1",
25 EXECUTOR_DATA_DIR: "/home/alice/Executor data",
26 PATH: '/home/alice/bin:/opt/Bad "Dir"/bin',
27 },
28 workingDirectory: "/home/alice/Executor data",
29 stdoutPath: "/home/alice/Executor data/logs/daemon.log",
30 stderrPath: "/home/alice/Executor data/logs/daemon.error.log",
31 });
32
33 const unsafeFragments = [
34 'ExecStart="/home/alice/Executor "Beta"/executor"',
35 "Environment=EXECUTOR_DATA_DIR=/home/alice/Executor data",
36 'Environment=PATH=/home/alice/bin:/opt/Bad "Dir"/bin',
37 "WorkingDirectory=/home/alice/Executor data",
38 "StandardOutput=append:/home/alice/Executor data/logs/daemon.log",
39 "StandardError=append:/home/alice/Executor data/logs/daemon.error.log",
40 ].filter((fragment) => unit.includes(fragment));
41
42 expect(unsafeFragments, `unsafe raw fragments in unit:\n${unit}`).toEqual([]);
43 }),
44);
e2e/local/vite-dev-routing.test.ts+228−0
@@ -0,0 +1,228 @@
1// Local-only: plain `apps/local` Vite dev must route the same local-only HTTP
2// surfaces as production `executor web`. These routes live outside the typed
3// `/api` HttpApi: `/api/health`, `/api/oauth/await/*`, and browser approval's
4// `/api/mcp-sessions/*`.
5import { type ChildProcess, spawn } from "node:child_process";
6import { randomBytes } from "node:crypto";
7import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8import net from "node:net";
9import { tmpdir } from "node:os";
10import { join } from "node:path";
11import { fileURLToPath } from "node:url";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
18import { scenario } from "../src/scenario";
19import { waitForHttp } from "../setup/boot";
20
21const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22const localAppDir = join(repoRoot, "apps/local");
23
24const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
25const EXECUTE_CODE = `
26const result = await tools.executor.coreTools.policies.list({});
27return JSON.stringify(result);
28`;
29
30const freePort = (): Promise<number> =>
31 new Promise((resolve, reject) => {
32 const srv = net.createServer();
33 srv.on("error", reject);
34 srv.listen(0, "127.0.0.1", () => {
35 const port = (srv.address() as net.AddressInfo).port;
36 srv.close(() => resolve(port));
37 });
38 });
39
40const readToken = async (dataDir: string): Promise<string> => {
41 const path = join(dataDir, "server-control", "auth.json");
42 const deadline = Date.now() + 30_000;
43 while (Date.now() < deadline) {
44 if (existsSync(path)) {
45 const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown };
46 if (typeof parsed.token === "string") return parsed.token;
47 }
48 await new Promise((resolve) => setTimeout(resolve, 100));
49 }
50 throw new Error(`Vite dev did not mint ${path}`);
51};
52
53const stopTree = async (child: ChildProcess): Promise<void> => {
54 if (child.pid === undefined || child.exitCode !== null) return;
55 try {
56 process.kill(-child.pid, "SIGTERM");
57 } catch {
58 child.kill("SIGTERM");
59 }
60 await Promise.race([
61 new Promise((resolve) => child.once("exit", resolve)),
62 new Promise((resolve) => setTimeout(resolve, 5_000)),
63 ]);
64 if (child.exitCode === null) {
65 try {
66 process.kill(-child.pid, "SIGKILL");
67 } catch {
68 child.kill("SIGKILL");
69 }
70 }
71};
72
73interface ViteDev {
74 readonly origin: string;
75 readonly token: string;
76 readonly stop: () => Promise<void>;
77}
78
79const startPlainViteDev = async (): Promise<ViteDev> => {
80 const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-"));
81 const port = await freePort();
82 const origin = `http://127.0.0.1:${port}`;
83 let logs = "";
84 const child = spawn(
85 "bunx",
86 ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
87 {
88 cwd: localAppDir,
89 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) },
90 stdio: ["ignore", "pipe", "pipe"],
91 detached: true,
92 },
93 );
94 child.stdout?.on("data", (chunk: Buffer) => {
95 logs += chunk.toString();
96 });
97 child.stderr?.on("data", (chunk: Buffer) => {
98 logs += chunk.toString();
99 });
100
101 try {
102 await waitForHttp(origin, { timeoutMs: 90_000 });
103 const token = await readToken(dataDir);
104 return {
105 origin,
106 token,
107 stop: async () => {
108 await stopTree(child);
109 rmSync(dataDir, { recursive: true, force: true });
110 },
111 };
112 } catch (error) {
113 await stopTree(child);
114 rmSync(dataDir, { recursive: true, force: true });
115 throw new Error(`plain Vite dev failed to boot:\n${logs}\n${String(error)}`);
116 }
117};
118
119scenario(
120 "Local Vite dev · local-only API routes match production routing",
121 { timeout: 180_000 },
122 Effect.gen(function* () {
123 const vite = yield* Effect.promise(() => startPlainViteDev());
124 yield* Effect.promise(async () => {
125 const failures: string[] = [];
126 let policyId: string | null = null;
127 const auth = { authorization: `Bearer ${vite.token}` };
128
129 try {
130 const health = await fetch(`${vite.origin}/api/health`);
131 const healthText = await health.text();
132 if (health.status !== 200 || healthText !== "ok") {
133 failures.push(`/api/health returned ${health.status} ${JSON.stringify(healthText)}`);
134 }
135
136 const awaited = await fetch(`${vite.origin}/api/oauth/await/session-1`, {
137 headers: auth,
138 });
139 const awaitedText = await awaited.text();
140 if (awaited.status !== 200 || awaitedText !== "null") {
141 failures.push(
142 `/api/oauth/await/session-1 returned ${awaited.status} ${JSON.stringify(awaitedText)}`,
143 );
144 }
145
146 const created = await fetch(`${vite.origin}/api/policies`, {
147 method: "POST",
148 headers: { ...auth, "content-type": "application/json" },
149 body: JSON.stringify({
150 owner: "org",
151 pattern: APPROVAL_TARGET_TOOL,
152 action: "require_approval",
153 }),
154 });
155 if (!created.ok) {
156 failures.push(`/api/policies setup returned ${created.status} ${await created.text()}`);
157 } else {
158 const policy = (await created.json()) as { readonly id?: string };
159 policyId = typeof policy.id === "string" ? policy.id : null;
160
161 const mcp = new Client(
162 { name: `vite-routing-${randomBytes(3).toString("hex")}`, version: "1.0.0" },
163 { capabilities: {} },
164 );
165 const transport = new StreamableHTTPClientTransport(
166 new URL(`${vite.origin}/mcp?elicitation_mode=browser`),
167 { requestInit: { headers: auth } },
168 );
169 await mcp.connect(transport);
170 try {
171 const executed = await mcp.callTool({
172 name: "execute",
173 arguments: { code: EXECUTE_CODE },
174 });
175 const paused = executed.structuredContent as {
176 readonly status?: string;
177 readonly executionId?: string;
178 readonly approvalUrl?: string;
179 };
180 if (
181 paused.status !== "user_approval_required" ||
182 typeof paused.executionId !== "string" ||
183 typeof paused.approvalUrl !== "string"
184 ) {
185 failures.push(
186 `MCP setup did not produce a browser approval: ${JSON.stringify(paused)}`,
187 );
188 } else {
189 const approvalUrl = new URL(paused.approvalUrl);
190 const sessionId = approvalUrl.searchParams.get("mcp_session_id");
191 if (!sessionId) {
192 failures.push(`approval URL had no mcp_session_id: ${paused.approvalUrl}`);
193 } else {
194 const detail = await fetch(
195 `${vite.origin}/api/mcp-sessions/${encodeURIComponent(
196 sessionId,
197 )}/executions/${encodeURIComponent(paused.executionId)}`,
198 { headers: auth },
199 );
200 if (detail.status !== 200) {
201 failures.push(
202 `/api/mcp-sessions paused-detail returned ${detail.status} ${await detail.text()}`,
203 );
204 }
205 }
206 }
207 } finally {
208 await mcp.close();
209 }
210 }
211 } finally {
212 if (policyId) {
213 await fetch(`${vite.origin}/api/policies/${encodeURIComponent(policyId)}`, {
214 method: "DELETE",
215 headers: { ...auth, "content-type": "application/json" },
216 body: JSON.stringify({ owner: "org" }),
217 }).catch(() => {});
218 }
219 await vite.stop();
220 }
221
222 expect(
223 failures,
224 "plain apps/local Vite dev should special-case the same local-only routes as production",
225 ).toEqual([]);
226 });
227 }),
228);
e2e/selfhost/mcp-browser-approval-ownership.test.ts+138−0
@@ -0,0 +1,138 @@
1// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A
2// signed-in user who does not own the MCP session must not be able to read the
3// paused execution or record the human decision for it.
4import { randomBytes } from "node:crypto";
5
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8import { composePluginApi } from "@executor-js/api/server";
9
10import { scenario } from "../src/scenario";
11import { Api, Mcp, Target } from "../src/services";
12import { parseBrowserApproval } from "../src/surfaces/mcp";
13import type { Identity } from "../src/target";
14import { signInSession } from "../targets/selfhost";
15
16const coreApi = composePluginApi([] as const);
17
18const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
19const EXECUTE_CODE = `
20const result = await tools.executor.coreTools.policies.list({});
21return JSON.stringify(result);
22`;
23
24const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
25 const cookie = admin.headers?.cookie;
26 expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
27
28 const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 cookie: cookie!,
33 origin: new URL(baseUrl).origin,
34 },
35 body: JSON.stringify({ role: "member" }),
36 });
37 expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
38 const inviteBody = (await invite.json()) as { readonly code?: string };
39 expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
40
41 const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`;
42 const password = "approval-cross-user-password-123";
43 const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
44 method: "POST",
45 headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
46 body: JSON.stringify({
47 email,
48 password,
49 name: email,
50 inviteCode: inviteBody.code,
51 }),
52 });
53 expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
54
55 const session = await signInSession(baseUrl, { email, password });
56 return {
57 label: email,
58 credentials: { email, password },
59 headers: { cookie: session.cookieHeader },
60 cookies: session.cookies,
61 };
62};
63
64const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL =>
65 new URL(
66 `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(
67 executionId,
68 )}`,
69 baseUrl,
70 );
71
72scenario(
73 "MCP browser approval · another self-host user cannot act on someone else's paused session",
74 { timeout: 180_000 },
75 Effect.gen(function* () {
76 const target = yield* Target;
77 const api = yield* Api;
78 const mcp = yield* Mcp;
79 const owner = yield* target.newIdentity();
80 const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner));
81 const client = yield* api.client(coreApi, owner);
82
83 const policy = yield* client.policies.create({
84 payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
85 });
86
87 yield* Effect.gen(function* () {
88 const session = mcp.session(owner, { elicitationMode: "browser" });
89 yield* session.listTools();
90
91 const paused = yield* session.call("execute", { code: EXECUTE_CODE });
92 const approval = parseBrowserApproval(paused);
93 const approvalUrl = new URL(approval.approvalUrl);
94 const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
95 expect(typeof mcpSessionId, "approval URL is tied to the MCP session").toBe("string");
96
97 const otherHeaders = {
98 cookie: other.headers!.cookie,
99 origin: new URL(target.baseUrl).origin,
100 };
101 const detail = yield* Effect.promise(() =>
102 fetch(approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId), {
103 headers: otherHeaders,
104 }),
105 );
106 expect([403, 404], "a different signed-in user cannot read the paused execution").toContain(
107 detail.status,
108 );
109
110 const decision = yield* Effect.promise(() =>
111 fetch(
112 new URL(
113 `${approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId).pathname}/resume`,
114 target.baseUrl,
115 ),
116 {
117 method: "POST",
118 headers: {
119 ...otherHeaders,
120 "content-type": "application/json",
121 },
122 body: JSON.stringify({ action: "accept" }),
123 },
124 ),
125 );
126 expect(
127 [403, 404],
128 "a different signed-in user cannot approve the paused execution",
129 ).toContain(decision.status);
130 }).pipe(
131 Effect.ensuring(
132 client.policies
133 .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
134 .pipe(Effect.ignore),
135 ),
136 );
137 }),
138);
packages/hosts/mcp/src/in-memory-session-store.ts+34−8
@@ -82,13 +82,19 @@ export interface InMemoryMcpSessionStore { · 81 unmodified lines
82 * paused-execution detail the console approval page renders. Returns the82 * paused-execution detail the console approval page renders. Returns the
83 * paused `{ text, structured }` or a 404. Null if the path does not match.83 * paused `{ text, structured }` or a 404. Null if the path does not match.
84 */84 */
85 readonly handlePausedRequest: (request: Request) => Promise<Response | null>;85 readonly handlePausedRequest: (
86 request: Request,
87 principal?: Principal,
88 ) => Promise<Response | null>;
86 /**89 /**
87 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —90 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —
88 * record the human's decision and wake the long-polling `resume` tool call.91 * record the human's decision and wake the long-polling `resume` tool call.
89 * Null if the path does not match.92 * Null if the path does not match.
90 */93 */
91 readonly handleApprovalRequest: (request: Request) => Promise<Response | null>;94 readonly handleApprovalRequest: (
95 request: Request,
96 principal?: Principal,
97 ) => Promise<Response | null>;
92 /** Dispose every live session — wire into the host's shutdown (not a seam). */98 /** Dispose every live session — wire into the host's shutdown (not a seam). */
93 readonly close: () => Promise<void>;99 readonly close: () => Promise<void>;
94}100}
@@ -246,6 +252,16 @@ export const makeInMemoryMcpSessionStore = ( · 151 unmodified lines
246 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),252 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
247 };253 };
248 254
255 const ownerAccess = (
256 sessionId: string,
257 principal: Principal | undefined,
258 ): "allowed" | "not-found" | "forbidden" => {
259 const owner = owners.get(sessionId);
260 if (!owner) return "not-found";
261 if (principal && !principalOwns(owner, principal)) return "forbidden";
262 return "allowed";
263 };
264
249 /** Resolve a paused execution from the session that owns it, for HTTP approval. */265 /** Resolve a paused execution from the session that owns it, for HTTP approval. */
250 const pausedFromSession = (266 const pausedFromSession = (
251 sessionId: string,267 sessionId: string,
@@ -261,25 +277,35 @@ export const makeInMemoryMcpSessionStore = ( · 9 unmodified lines
261 );277 );
262 };278 };
263 279
264 const handlePausedRequest = async (request: Request): Promise<Response | null> => {280 const handlePausedRequest = async (
281 request: Request,
282 principal?: Principal,
283 ): Promise<Response | null> => {
265 const match = PAUSED_PATH.exec(new URL(request.url).pathname);284 const match = PAUSED_PATH.exec(new URL(request.url).pathname);
266 if (!match) return null;285 if (!match) return null;
267 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);286 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
268 const paused = await pausedFromSession(287 const sessionId = decodeURIComponent(match[1]!);
269 decodeURIComponent(match[1]!),288 const access = ownerAccess(sessionId, principal);
270 decodeURIComponent(match[2]!),289 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
271 );290 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
291 const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!));
272 if (!paused) return json({ error: "Paused execution not found" }, 404);292 if (!paused) return json({ error: "Paused execution not found" }, 404);
273 return json({ text: paused.text, structured: paused.structured });293 return json({ text: paused.text, structured: paused.structured });
274 };294 };
275 295
276 const handleApprovalRequest = async (request: Request): Promise<Response | null> => {296 const handleApprovalRequest = async (
297 request: Request,
298 principal?: Principal,
299 ): Promise<Response | null> => {
277 const match = RESUME_PATH.exec(new URL(request.url).pathname);300 const match = RESUME_PATH.exec(new URL(request.url).pathname);
278 if (!match) return null;301 if (!match) return null;
279 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);302 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
280 303
281 const sessionId = decodeURIComponent(match[1]!);304 const sessionId = decodeURIComponent(match[1]!);
282 const executionId = decodeURIComponent(match[2]!);305 const executionId = decodeURIComponent(match[2]!);
306 const access = ownerAccess(sessionId, principal);
307 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
308 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
283 // The session must still hold the paused execution — guards stale ids and309 // The session must still hold the paused execution — guards stale ids and
284 // confirms the execution belongs to this session before recording.310 // confirms the execution belongs to this session before recording.
285 const paused = await pausedFromSession(sessionId, executionId);311 const paused = await pausedFromSession(sessionId, executionId);