Unified · Split
| @@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( · 88 unmodified lines | ||
| 89 | 89 | // misconfigured base URL can't leak the bearer token to a third-party host. |
| 90 | 90 | const url = new URL("/api/health", input.baseUrl); |
| 91 | 91 | 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"; | |
| 94 | 94 | }).pipe(Effect.catchCause(() => Effect.succeed(false))); |
| 95 | 95 | |
| 96 | 96 | // --------------------------------------------------------------------------- |
| @@ -2270,8 +2270,8 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 2269 unmodified lines | ||
| 2270 | 2270 | */ |
| 2271 | 2271 | const openCommand = Command.make("open", {}, () => |
| 2272 | 2272 | 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) { | |
| 2275 | 2275 | console.log("No local Executor server is running."); |
| 2276 | 2276 | console.log(`Start one with: ${cliPrefix} web`); |
| 2277 | 2277 | return; |
| @@ -340,11 +340,24 @@ export interface SystemdUnitOptions { · 339 unmodified lines | ||
| 340 | 340 | readonly stderrPath: string; |
| 341 | 341 | } |
| 342 | 342 | |
| 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 | + | |
| 343 | 356 | /** Render a systemd --user unit. Pure (snapshot-tested). */ |
| 344 | 357 | 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(" "); | |
| 346 | 359 | const env = Object.entries(options.environment) |
| 347 | − .map(([key, value]) => `Environment=${key}=${value}`) | |
| 360 | + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) | |
| 348 | 361 | .join("\n"); |
| 349 | 362 | return `[Unit] |
| 350 | 363 | Description=Executor supervised daemon |
| @@ -354,9 +367,9 @@ After=default.target · 3 unmodified lines | ||
| 354 | 367 | Type=simple |
| 355 | 368 | ExecStart=${execStart} |
| 356 | 369 | ${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}`)} | |
| 360 | 373 | Restart=on-failure |
| 361 | 374 | RestartSec=5s |
| 362 | 375 | |
| @@ -415,12 +415,23 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 414 unmodified lines | ||
| 415 | 415 | }; |
| 416 | 416 | |
| 417 | 417 | 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. | |
| 420 | 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 | 432 | 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); | |
| 424 | 435 | } |
| 425 | 436 | if (connection) { |
| 426 | 437 | await stopConnection(connection); |
| @@ -408,20 +408,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 407 unmodified lines | ||
| 408 | 408 | }); |
| 409 | 409 | } |
| 410 | 410 | |
| 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> => { | |
| 417 | 413 | const controller = new AbortController(); |
| 418 | 414 | const timer = setTimeout(() => controller.abort(), 1500); |
| 419 | 415 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal |
| 420 | 416 | 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"; | |
| 425 | 423 | } catch { |
| 426 | 424 | return false; |
| 427 | 425 | } finally { |
| @@ -452,7 +450,7 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 24 unmodified lines | ||
| 452 | 450 | const origin = manifest.connection.origin; |
| 453 | 451 | const auth = manifest.connection.auth; |
| 454 | 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 | 455 | const url = new URL(origin); |
| 458 | 456 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); |
| @@ -63,7 +63,7 @@ if (sentryDsn) { · 62 unmodified lines | ||
| 63 | 63 | }); |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | −import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 66 | +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 67 | 67 | import { homedir } from "node:os"; |
| 68 | 68 | import { |
| 69 | 69 | normalizeExecutorServerConnection, |
| @@ -116,7 +116,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines | ||
| 116 | 116 | executablePath: process.execPath || null, |
| 117 | 117 | }, |
| 118 | 118 | }), |
| 119 | + { mode: 0o600 }, | |
| 119 | 120 | ); |
| 121 | + chmodSync(manifestPath, 0o600); | |
| 120 | 122 | }; |
| 121 | 123 | |
| 122 | 124 | const removeOwnManifest = () => { |
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | |
| 3 | 3 | 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"; | |
| 5 | 10 | |
| 6 | 11 | import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; |
| 7 | 12 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| @@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines | ||
| 64 | 69 | const jsonResponse = (value: unknown, status: number): Response => |
| 65 | 70 | new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); |
| 66 | 71 | |
| 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 | + | |
| 67 | 95 | /** |
| 68 | 96 | * Gate the browser-approval endpoints behind a valid Better Auth session (the |
| 69 | 97 | * 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. | |
| 73 | 101 | */ |
| 74 | 102 | const makeApprovalHandler = |
| 75 | 103 | ( |
| @@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines | ||
| 85 | 113 | }).pipe(Effect.orElseSucceed(() => null)), |
| 86 | 114 | ); |
| 87 | 115 | if (!session) return jsonResponse({ error: "Unauthorized" }, 401); |
| 116 | + const principal = principalFromSession(session, betterAuth); | |
| 88 | 117 | |
| 89 | 118 | return ( |
| 90 | − (await store.handlePausedRequest(request)) ?? | |
| 91 | − (await store.handleApprovalRequest(request)) ?? | |
| 119 | + (await store.handlePausedRequest(request, principal)) ?? | |
| 120 | + (await store.handleApprovalRequest(request, principal)) ?? | |
| 92 | 121 | jsonResponse({ error: "Not found" }, 404) |
| 93 | 122 | ); |
| 94 | 123 | }; |
| @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines | ||
| 5 | 5 | import { defineConfig, type Plugin } from "vite"; |
| 6 | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | +import { consumeOAuthResult } from "./src/oauth-result-store"; | |
| 8 | 9 | import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; |
| 9 | 10 | |
| 10 | 11 | // 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 | 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 | 118 | 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 | + } | |
| 131 | 149 | |
| 132 | 150 | res.statusCode = response.status; |
| 133 | 151 | response.headers.forEach((v, k) => res.setHeader(k, v)); |
| @@ -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 | +}; | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -82,13 +82,19 @@ export interface InMemoryMcpSessionStore { · 81 unmodified lines | ||
| 82 | 82 | * paused-execution detail the console approval page renders. Returns the |
| 83 | 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 | 90 | * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — |
| 88 | 91 | * record the human's decision and wake the long-polling `resume` tool call. |
| 89 | 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 | 98 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ |
| 93 | 99 | readonly close: () => Promise<void>; |
| 94 | 100 | } |
| @@ -246,6 +252,16 @@ export const makeInMemoryMcpSessionStore = ( · 151 unmodified lines | ||
| 246 | 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 | 265 | /** Resolve a paused execution from the session that owns it, for HTTP approval. */ |
| 250 | 266 | const pausedFromSession = ( |
| 251 | 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 | 284 | const match = PAUSED_PATH.exec(new URL(request.url).pathname); |
| 266 | 285 | if (!match) return null; |
| 267 | 286 | 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]!)); | |
| 272 | 292 | if (!paused) return json({ error: "Paused execution not found" }, 404); |
| 273 | 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 | 300 | const match = RESUME_PATH.exec(new URL(request.url).pathname); |
| 278 | 301 | if (!match) return null; |
| 279 | 302 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); |
| 280 | 303 | |
| 281 | 304 | const sessionId = decodeURIComponent(match[1]!); |
| 282 | 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 | 309 | // The session must still hold the paused execution — guards stale ids and |
| 284 | 310 | // confirms the execution belongs to this session before recording. |
| 285 | 311 | const paused = await pausedFromSession(sessionId, executionId); |