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
8989 // misconfigured base URL can't leak the bearer token to a third-party host.
9090 const url = new URL("/api/health", input.baseUrl);
9191 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
92− await response.body?.cancel();
93− return response.ok;
92+ const body = await response.text();
93+ return response.ok && body.trim() === "ok";
9494 }).pipe(Effect.catchCause(() => Effect.succeed(false)));
9595
9696 // ---------------------------------------------------------------------------
apps/cli/src/main.ts+2−2
@@ -2270,8 +2270,8 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 2269 unmodified lines
22702270 */
22712271 const openCommand = Command.make("open", {}, () =>
22722272 Effect.gen(function* () {
2273− const manifest = yield* readLocalServerManifest();
2274− if (!manifest || !isPidAlive(manifest.pid)) {
2273+ const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2274+ if (!manifest) {
22752275 console.log("No local Executor server is running.");
22762276 console.log(`Start one with: ${cliPrefix} web`);
22772277 return;
apps/cli/src/service.ts+18−5
@@ -340,11 +340,24 @@ export interface SystemdUnitOptions { · 339 unmodified lines
340340 readonly stderrPath: string;
341341 }
342342
343+const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
344+
345+const 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+
343356 /** Render a systemd --user unit. Pure (snapshot-tested). */
344357 export 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(" ");
346359 const env = Object.entries(options.environment)
347− .map(([key, value]) => `Environment=${key}=${value}`)
360+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
348361 .join("\n");
349362 return `[Unit]
350363 Description=Executor supervised daemon
@@ -354,9 +367,9 @@ After=default.target · 3 unmodified lines
354367 Type=simple
355368 ExecStart=${execStart}
356369 ${env}
357−WorkingDirectory=${options.workingDirectory}
358−StandardOutput=append:${options.stdoutPath}
359−StandardError=append:${options.stderrPath}
370+WorkingDirectory=${systemdQuote(options.workingDirectory)}
371+StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)}
372+StandardError=${systemdQuote(`append:${options.stderrPath}`)}
360373 Restart=on-failure
361374 RestartSec=5s
362375
apps/desktop/src/main/index.ts+15−4
@@ -415,12 +415,23 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 414 unmodified lines
415415 };
416416
417417 const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {
418− // A supervised daemon isn't ours to restart — just reload the window against
419− // the same endpoint instead of tearing down a process we don't own.
418+ // A supervised daemon owns its own process lifetime. Re-installing the unit
419+ // rewrites settings such as the configured port, then launchd restarts it.
420420 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);
421432 const window = liveMainWindow();
422− if (window) await window.loadURL(connection.baseUrl);
423− return toDesktopServerConnection(connection);
433+ if (window) await window.loadURL(next.baseUrl);
434+ return toDesktopServerConnection(next);
424435 }
425436 if (connection) {
426437 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
408408 });
409409 }
410410
411−/**
412− * Probe whether an HTTP server is listening at `origin`. Any HTTP response —
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− */
416−const isDaemonReachable = async (origin: string, authToken: string): Promise<boolean> => {
411+/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
412+const isDaemonReachable = async (origin: string): Promise<boolean> => {
417413 const controller = new AbortController();
418414 const timer = setTimeout(() => controller.abort(), 1500);
419415 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal
420416 try {
421− const headers: Record<string, string> = {};
422− if (authToken) headers.Authorization = `Bearer ${authToken}`;
423− await fetch(origin, { signal: controller.signal, headers, redirect: "manual" });
424− return true;
417+ const response = await fetch(new URL("/api/health", origin), {
418+ signal: controller.signal,
419+ redirect: "manual",
420+ });
421+ const body = await response.text();
422+ return response.ok && body.trim() === "ok";
425423 } catch {
426424 return false;
427425 } finally {
@@ -452,7 +450,7 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 24 unmodified lines
452450 const origin = manifest.connection.origin;
453451 const auth = manifest.connection.auth;
454452 const authToken = auth && auth.kind === "bearer" ? auth.token : "";
455− if (!(await isDaemonReachable(origin, authToken))) return null;
453+ if (!(await isDaemonReachable(origin))) return null;
456454
457455 const url = new URL(origin);
458456 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
6363 });
6464 }
6565
66−import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
66+import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6767 import { homedir } from "node:os";
6868 import {
6969 normalizeExecutorServerConnection,
@@ -116,7 +116,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines
116116 executablePath: process.execPath || null,
117117 },
118118 }),
119+ { mode: 0o600 },
119120 );
121+ chmodSync(manifestPath, 0o600);
120122 };
121123
122124 const removeOwnManifest = () => {
apps/host-selfhost/src/mcp/index.ts+35−6
@@ -1,7 +1,12 @@
11 import { Effect, Layer } from "effect";
22
33 import { IdentityProvider } from "@executor-js/api/server";
4−import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";
4+import type {
5+ McpAuthProvider,
6+ McpErrorReporter,
7+ McpSessionStore,
8+ Principal,
9+} from "@executor-js/host-mcp";
510
611 import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
712 import type { SelfHostDbHandle } from "../db/self-host-db";
@@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines
6469 const jsonResponse = (value: unknown, status: number): Response =>
6570 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
6671
72+const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
73+ (role ?? "user")
74+ .split(",")
75+ .map((r) => r.trim())
76+ .filter((r) => r.length > 0);
77+
78+type BetterAuthSession = NonNullable<
79+ Awaited<ReturnType<BetterAuthHandle["auth"]["api"]["getSession"]>>
80+>;
81+
82+const 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+
6795 /**
6896 * Gate the browser-approval endpoints behind a valid Better Auth session (the
6997 * console page calls them with the user's cookie), then delegate to the
70− * in-process store's paused/resume handlers. Single-tenant: any authenticated
71− * user of the one org may act on a session it still holds — the store confirms
72− * the execution belongs to the addressed session before recording.
98+ * in-process store's paused/resume handlers with the resolved principal so the
99+ * store can enforce MCP session ownership before exposing or recording a
100+ * browser-approval decision.
73101 */
74102 const makeApprovalHandler =
75103 (
@@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines
85113 }).pipe(Effect.orElseSucceed(() => null)),
86114 );
87115 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);
116+ const principal = principalFromSession(session, betterAuth);
88117
89118 return (
90− (await store.handlePausedRequest(request)) ??
91− (await store.handleApprovalRequest(request)) ??
119+ (await store.handlePausedRequest(request, principal)) ??
120+ (await store.handleApprovalRequest(request, principal)) ??
92121 jsonResponse({ error: "Not found" }, 404)
93122 );
94123 };
apps/local/vite.config.ts+31−13
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
55 import { defineConfig, type Plugin } from "vite";
66 import appPlugin from "@executor-js/app/vite";
77 import { loadOrMintLocalAuthToken } from "./src/auth";
8+import { consumeOAuthResult } from "./src/oauth-result-store";
89 import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
910
1011 // 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
114115 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
115116 }
116117
117− // Strip /api prefix for Effect handlers
118− const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl;
119−
120118 const hasBody = req.method !== "GET" && req.method !== "HEAD";
121− const webRequest = new Request(new URL(url, origin), {
122− method: req.method,
123− headers,
124− body: hasBody ? Readable.toWeb(req) : undefined,
125− duplex: hasBody ? "half" : undefined,
126− } as RequestInit);
127−
128− const response = isMcp
129− ? await handlers.mcp.handleRequest(webRequest)
130− : await handlers.api.handler(webRequest);
119+ const webRequest = (url: string): Request =>
120+ new Request(new URL(url, origin), {
121+ method: req.method,
122+ headers,
123+ body: hasBody ? Readable.toWeb(req) : undefined,
124+ duplex: hasBody ? "half" : undefined,
125+ } as RequestInit);
126+
127+ let response: Response;
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+ }
131149
132150 res.statusCode = response.status;
133151 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`).
4+import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process";
5+import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
6+import { createServer, type IncomingMessage } from "node:http";
7+import net from "node:net";
8+import { tmpdir } from "node:os";
9+import { dirname, join } from "node:path";
10+import { promisify } from "node:util";
11+
12+import { expect, it } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { _electron, type ElectronApplication } from "playwright";
15+import {
16+ normalizeExecutorServerConnection,
17+ serializeExecutorLocalServerManifest,
18+} from "@executor-js/sdk/shared";
19+
20+import { scenario } from "../src/scenario";
21+import { RunDir } from "../src/services";
22+import { waitForHttp } from "../setup/boot";
23+
24+interface 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+
31+declare global {
32+ interface Window {
33+ readonly executor: PackagedExecutorBridge;
34+ }
35+}
36+
37+const appExe = process.env.E2E_DESKTOP_APP_EXE;
38+const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN;
39+const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : "";
40+
41+const 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+
54+const 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+
68+const 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+
77+const 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+
87+interface DaemonStart {
88+ readonly child: ChildProcess;
89+ readonly ready: boolean;
90+ readonly stderr: string;
91+}
92+
93+const 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+
120+const 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+
150+scenario(
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+
191+if (!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+
213+const 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+
223+const 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+
286+const 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.
3+import { execFile } from "node:child_process";
4+import { randomBytes } from "node:crypto";
5+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+import net from "node:net";
7+import { tmpdir } from "node:os";
8+import { delimiter, join } from "node:path";
9+import { fileURLToPath } from "node:url";
10+import { promisify } from "node:util";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import {
15+ normalizeExecutorServerConnection,
16+ serializeExecutorLocalServerManifest,
17+} from "@executor-js/sdk/shared";
18+
19+import { scenario } from "../src/scenario";
20+
21+const execFileAsync = promisify(execFile);
22+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
23+
24+const 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+
34+const openerName = (): string =>
35+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
36+
37+scenario(
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.
4+import { expect } from "@effect/vitest";
5+import { Effect } from "effect";
6+
7+import { generateSystemdUnit } from "../../apps/cli/src/service";
8+import { scenario } from "../src/scenario";
9+
10+scenario(
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/*`.
5+import { type ChildProcess, spawn } from "node:child_process";
6+import { randomBytes } from "node:crypto";
7+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8+import net from "node:net";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
11+import { fileURLToPath } from "node:url";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
18+import { scenario } from "../src/scenario";
19+import { waitForHttp } from "../setup/boot";
20+
21+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22+const localAppDir = join(repoRoot, "apps/local");
23+
24+const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
25+const EXECUTE_CODE = `
26+const result = await tools.executor.coreTools.policies.list({});
27+return JSON.stringify(result);
28+`;
29+
30+const 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+
40+const 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+
53+const 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+
73+interface ViteDev {
74+ readonly origin: string;
75+ readonly token: string;
76+ readonly stop: () => Promise<void>;
77+}
78+
79+const 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+
119+scenario(
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.
4+import { randomBytes } from "node:crypto";
5+
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+import { composePluginApi } from "@executor-js/api/server";
9+
10+import { scenario } from "../src/scenario";
11+import { Api, Mcp, Target } from "../src/services";
12+import { parseBrowserApproval } from "../src/surfaces/mcp";
13+import type { Identity } from "../src/target";
14+import { signInSession } from "../targets/selfhost";
15+
16+const coreApi = composePluginApi([] as const);
17+
18+const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
19+const EXECUTE_CODE = `
20+const result = await tools.executor.coreTools.policies.list({});
21+return JSON.stringify(result);
22+`;
23+
24+const 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+
64+const 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+
72+scenario(
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
8282 * paused-execution detail the console approval page renders. Returns the
8383 * paused `{ text, structured }` or a 404. Null if the path does not match.
8484 */
85− readonly handlePausedRequest: (request: Request) => Promise<Response | null>;
85+ readonly handlePausedRequest: (
86+ request: Request,
87+ principal?: Principal,
88+ ) => Promise<Response | null>;
8689 /**
8790 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —
8891 * record the human's decision and wake the long-polling `resume` tool call.
8992 * Null if the path does not match.
9093 */
91− readonly handleApprovalRequest: (request: Request) => Promise<Response | null>;
94+ readonly handleApprovalRequest: (
95+ request: Request,
96+ principal?: Principal,
97+ ) => Promise<Response | null>;
9298 /** Dispose every live session — wire into the host's shutdown (not a seam). */
9399 readonly close: () => Promise<void>;
94100 }
@@ -246,6 +252,16 @@ export const makeInMemoryMcpSessionStore = ( · 151 unmodified lines
246252 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
247253 };
248254
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+
249265 /** Resolve a paused execution from the session that owns it, for HTTP approval. */
250266 const pausedFromSession = (
251267 sessionId: string,
@@ -261,25 +277,35 @@ export const makeInMemoryMcpSessionStore = ( · 9 unmodified lines
261277 );
262278 };
263279
264− const handlePausedRequest = async (request: Request): Promise<Response | null> => {
280+ const handlePausedRequest = async (
281+ request: Request,
282+ principal?: Principal,
283+ ): Promise<Response | null> => {
265284 const match = PAUSED_PATH.exec(new URL(request.url).pathname);
266285 if (!match) return null;
267286 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
268− const paused = await pausedFromSession(
269− decodeURIComponent(match[1]!),
270− decodeURIComponent(match[2]!),
271− );
287+ const sessionId = decodeURIComponent(match[1]!);
288+ const access = ownerAccess(sessionId, principal);
289+ if (access === "forbidden") return json({ error: "Forbidden" }, 403);
290+ if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
291+ const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!));
272292 if (!paused) return json({ error: "Paused execution not found" }, 404);
273293 return json({ text: paused.text, structured: paused.structured });
274294 };
275295
276− const handleApprovalRequest = async (request: Request): Promise<Response | null> => {
296+ const handleApprovalRequest = async (
297+ request: Request,
298+ principal?: Principal,
299+ ): Promise<Response | null> => {
277300 const match = RESUME_PATH.exec(new URL(request.url).pathname);
278301 if (!match) return null;
279302 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
280303
281304 const sessionId = decodeURIComponent(match[1]!);
282305 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);
283309 // The session must still hold the paused execution — guards stale ids and
284310 // confirms the execution belongs to this session before recording.
285311 const paused = await pausedFromSession(sessionId, executionId);