Unified · Split
| @@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( · 88 unmodified lines | |||
| 89 | // misconfigured base URL can't leak the bearer token to a third-party host. | 89 | // misconfigured base URL can't leak the bearer token to a third-party host. |
| 90 | const url = new URL("/api/health", input.baseUrl); | 90 | const url = new URL("/api/health", input.baseUrl); |
| 91 | const response = await fetch(url, { signal: AbortSignal.timeout(2000) }); | 91 | const response = await fetch(url, { signal: AbortSignal.timeout(2000) }); |
| 92 | await response.body?.cancel(); | 92 | const body = await response.text(); |
| 93 | return response.ok; | 93 | return response.ok && body.trim() === "ok"; |
| 94 | }).pipe(Effect.catchCause(() => Effect.succeed(false))); | 94 | }).pipe(Effect.catchCause(() => Effect.succeed(false))); |
| 95 | 95 | ||
| 96 | // --------------------------------------------------------------------------- | 96 | // --------------------------------------------------------------------------- |
| @@ -2270,8 +2270,8 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 2269 unmodified lines | |||
| 2270 | */ | 2270 | */ |
| 2271 | const openCommand = Command.make("open", {}, () => | 2271 | const openCommand = Command.make("open", {}, () => |
| 2272 | Effect.gen(function* () { | 2272 | Effect.gen(function* () { |
| 2273 | const manifest = yield* readLocalServerManifest(); | 2273 | const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); |
| 2274 | if (!manifest || !isPidAlive(manifest.pid)) { | 2274 | if (!manifest) { |
| 2275 | console.log("No local Executor server is running."); | 2275 | console.log("No local Executor server is running."); |
| 2276 | console.log(`Start one with: ${cliPrefix} web`); | 2276 | console.log(`Start one with: ${cliPrefix} web`); |
| 2277 | return; | 2277 | return; |
| @@ -340,11 +340,24 @@ export interface SystemdUnitOptions { · 339 unmodified lines | |||
| 340 | readonly stderrPath: string; | 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 | /** Render a systemd --user unit. Pure (snapshot-tested). */ | 356 | /** Render a systemd --user unit. Pure (snapshot-tested). */ |
| 344 | export const generateSystemdUnit = (options: SystemdUnitOptions): string => { | 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 | const env = Object.entries(options.environment) | 359 | const env = Object.entries(options.environment) |
| 347 | .map(([key, value]) => `Environment=${key}=${value}`) | 360 | .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) |
| 348 | .join("\n"); | 361 | .join("\n"); |
| 349 | return `[Unit] | 362 | return `[Unit] |
| 350 | Description=Executor supervised daemon | 363 | Description=Executor supervised daemon |
| @@ -354,9 +367,9 @@ After=default.target · 3 unmodified lines | |||
| 354 | Type=simple | 367 | Type=simple |
| 355 | ExecStart=${execStart} | 368 | ExecStart=${execStart} |
| 356 | ${env} | 369 | ${env} |
| 357 | WorkingDirectory=${options.workingDirectory} | 370 | WorkingDirectory=${systemdQuote(options.workingDirectory)} |
| 358 | StandardOutput=append:${options.stdoutPath} | 371 | StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)} |
| 359 | StandardError=append:${options.stderrPath} | 372 | StandardError=${systemdQuote(`append:${options.stderrPath}`)} |
| 360 | Restart=on-failure | 373 | Restart=on-failure |
| 361 | RestartSec=5s | 374 | RestartSec=5s |
| 362 | 375 | ||
| @@ -415,12 +415,23 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 414 unmodified lines | |||
| 415 | }; | 415 | }; |
| 416 | 416 | ||
| 417 | const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { | 417 | const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { |
| 418 | // A supervised daemon isn't ours to restart — just reload the window against | 418 | // A supervised daemon owns its own process lifetime. Re-installing the unit |
| 419 | // the same endpoint instead of tearing down a process we don't own. | 419 | // rewrites settings such as the configured port, then launchd restarts it. |
| 420 | if (connection?.supervisedDaemon) { | 420 | if (connection?.supervisedDaemon) { |
| 421 | await installSupervisedService({ | ||
| 422 | port: getServerSettings().port, | ||
| 423 | dataDir: DESKTOP_DATA_DIR, | ||
| 424 | }); | ||
| 425 | const next = await waitForSupervisedAttach(30_000); | ||
| 426 | if (!next) { | ||
| 427 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call | ||
| 428 | throw new Error("Supervised daemon failed to restart — see Settings"); | ||
| 429 | } | ||
| 430 | connection = next; | ||
| 431 | installBearerAuthHeader(next.baseUrl, next.authToken); | ||
| 421 | const window = liveMainWindow(); | 432 | const window = liveMainWindow(); |
| 422 | if (window) await window.loadURL(connection.baseUrl); | 433 | if (window) await window.loadURL(next.baseUrl); |
| 423 | return toDesktopServerConnection(connection); | 434 | return toDesktopServerConnection(next); |
| 424 | } | 435 | } |
| 425 | if (connection) { | 436 | if (connection) { |
| 426 | await stopConnection(connection); | 437 | await stopConnection(connection); |
| @@ -408,20 +408,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 407 unmodified lines | |||
| 408 | }); | 408 | }); |
| 409 | } | 409 | } |
| 410 | 410 | ||
| 411 | /** | 411 | /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */ |
| 412 | * Probe whether an HTTP server is listening at `origin`. Any HTTP response — | 412 | const isDaemonReachable = async (origin: string): Promise<boolean> => { |
| 413 | * even 401/404 — means a server is up; only a network error or timeout counts | ||
| 414 | * as unreachable. Auth is attached so a 401 still confirms liveness cleanly. | ||
| 415 | */ | ||
| 416 | const isDaemonReachable = async (origin: string, authToken: string): Promise<boolean> => { | ||
| 417 | const controller = new AbortController(); | 413 | const controller = new AbortController(); |
| 418 | const timer = setTimeout(() => controller.abort(), 1500); | 414 | const timer = setTimeout(() => controller.abort(), 1500); |
| 419 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal | 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 | try { | 416 | try { |
| 421 | const headers: Record<string, string> = {}; | 417 | const response = await fetch(new URL("/api/health", origin), { |
| 422 | if (authToken) headers.Authorization = `Bearer ${authToken}`; | 418 | signal: controller.signal, |
| 423 | await fetch(origin, { signal: controller.signal, headers, redirect: "manual" }); | 419 | redirect: "manual", |
| 424 | return true; | 420 | }); |
| 421 | const body = await response.text(); | ||
| 422 | return response.ok && body.trim() === "ok"; | ||
| 425 | } catch { | 423 | } catch { |
| 426 | return false; | 424 | return false; |
| 427 | } finally { | 425 | } finally { |
| @@ -452,7 +450,7 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 24 unmodified lines | |||
| 452 | const origin = manifest.connection.origin; | 450 | const origin = manifest.connection.origin; |
| 453 | const auth = manifest.connection.auth; | 451 | const auth = manifest.connection.auth; |
| 454 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; | 452 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; |
| 455 | if (!(await isDaemonReachable(origin, authToken))) return null; | 453 | if (!(await isDaemonReachable(origin))) return null; |
| 456 | 454 | ||
| 457 | const url = new URL(origin); | 455 | const url = new URL(origin); |
| 458 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); | 456 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); |
| @@ -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 | import { homedir } from "node:os"; | 67 | import { homedir } from "node:os"; |
| 68 | import { | 68 | import { |
| 69 | normalizeExecutorServerConnection, | 69 | normalizeExecutorServerConnection, |
| @@ -116,7 +116,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines | |||
| 116 | executablePath: process.execPath || null, | 116 | executablePath: process.execPath || null, |
| 117 | }, | 117 | }, |
| 118 | }), | 118 | }), |
| 119 | { mode: 0o600 }, | ||
| 119 | ); | 120 | ); |
| 121 | chmodSync(manifestPath, 0o600); | ||
| 120 | }; | 122 | }; |
| 121 | 123 | ||
| 122 | const removeOwnManifest = () => { | 124 | const removeOwnManifest = () => { |
| @@ -1,7 +1,12 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { IdentityProvider } from "@executor-js/api/server"; | 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 | import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; | 11 | import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; |
| 7 | import type { SelfHostDbHandle } from "../db/self-host-db"; | 12 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| @@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines | |||
| 64 | const jsonResponse = (value: unknown, status: number): Response => | 69 | const jsonResponse = (value: unknown, status: number): Response => |
| 65 | new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); | 70 | new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); |
| 66 | 71 | ||
| 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 | * Gate the browser-approval endpoints behind a valid Better Auth session (the | 96 | * Gate the browser-approval endpoints behind a valid Better Auth session (the |
| 69 | * console page calls them with the user's cookie), then delegate to the | 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 | 98 | * in-process store's paused/resume handlers with the resolved principal so the |
| 71 | * user of the one org may act on a session it still holds — the store confirms | 99 | * store can enforce MCP session ownership before exposing or recording a |
| 72 | * the execution belongs to the addressed session before recording. | 100 | * browser-approval decision. |
| 73 | */ | 101 | */ |
| 74 | const makeApprovalHandler = | 102 | const makeApprovalHandler = |
| 75 | ( | 103 | ( |
| @@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines | |||
| 85 | }).pipe(Effect.orElseSucceed(() => null)), | 113 | }).pipe(Effect.orElseSucceed(() => null)), |
| 86 | ); | 114 | ); |
| 87 | if (!session) return jsonResponse({ error: "Unauthorized" }, 401); | 115 | if (!session) return jsonResponse({ error: "Unauthorized" }, 401); |
| 116 | const principal = principalFromSession(session, betterAuth); | ||
| 88 | 117 | ||
| 89 | return ( | 118 | return ( |
| 90 | (await store.handlePausedRequest(request)) ?? | 119 | (await store.handlePausedRequest(request, principal)) ?? |
| 91 | (await store.handleApprovalRequest(request)) ?? | 120 | (await store.handleApprovalRequest(request, principal)) ?? |
| 92 | jsonResponse({ error: "Not found" }, 404) | 121 | jsonResponse({ error: "Not found" }, 404) |
| 93 | ); | 122 | ); |
| 94 | }; | 123 | }; |
| @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines | |||
| 5 | import { defineConfig, type Plugin } from "vite"; | 5 | import { defineConfig, type Plugin } from "vite"; |
| 6 | import appPlugin from "@executor-js/app/vite"; | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; | 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | import { consumeOAuthResult } from "./src/oauth-result-store"; | ||
| 8 | import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; | 9 | import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; |
| 9 | 10 | ||
| 10 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json | 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 | if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); | 115 | if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); |
| 115 | } | 116 | } |
| 116 | 117 | ||
| 117 | // Strip /api prefix for Effect handlers | ||
| 118 | const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl; | ||
| 119 | |||
| 120 | const hasBody = req.method !== "GET" && req.method !== "HEAD"; | 118 | const hasBody = req.method !== "GET" && req.method !== "HEAD"; |
| 121 | const webRequest = new Request(new URL(url, origin), { | 119 | const webRequest = (url: string): Request => |
| 122 | method: req.method, | 120 | new Request(new URL(url, origin), { |
| 123 | headers, | 121 | method: req.method, |
| 124 | body: hasBody ? Readable.toWeb(req) : undefined, | 122 | headers, |
| 125 | duplex: hasBody ? "half" : undefined, | 123 | body: hasBody ? Readable.toWeb(req) : undefined, |
| 126 | } as RequestInit); | 124 | duplex: hasBody ? "half" : undefined, |
| 127 | 125 | } as RequestInit); | |
| 128 | const response = isMcp | 126 | |
| 129 | ? await handlers.mcp.handleRequest(webRequest) | 127 | let response: Response; |
| 130 | : await handlers.api.handler(webRequest); | 128 | if (isMcp) { |
| 129 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); | ||
| 130 | } else if (pathOnly === "/api/health" && req.method === "GET") { | ||
| 131 | response = new Response("ok", { headers: { "content-type": "text/plain" } }); | ||
| 132 | } else if (pathOnly.startsWith("/api/mcp-sessions/")) { | ||
| 133 | const handler = | ||
| 134 | req.method === "GET" | ||
| 135 | ? handlers.mcp.handlePausedRequest | ||
| 136 | : handlers.mcp.handleApprovalRequest; | ||
| 137 | response = await handler(webRequest(rawUrl)); | ||
| 138 | } else { | ||
| 139 | const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); | ||
| 140 | if (awaitMatch && req.method === "GET") { | ||
| 141 | response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), { | ||
| 142 | headers: { "content-type": "application/json" }, | ||
| 143 | }); | ||
| 144 | } else { | ||
| 145 | // Strip /api prefix for Effect handlers. | ||
| 146 | response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/")); | ||
| 147 | } | ||
| 148 | } | ||
| 131 | 149 | ||
| 132 | res.statusCode = response.status; | 150 | res.statusCode = response.status; |
| 133 | response.headers.forEach((v, k) => res.setHeader(k, v)); | 151 | response.headers.forEach((v, k) => res.setHeader(k, v)); |
| @@ -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 | * paused-execution detail the console approval page renders. Returns the | 82 | * paused-execution detail the console approval page renders. Returns the |
| 83 | * paused `{ text, structured }` or a 404. Null if the path does not match. | 83 | * paused `{ text, structured }` or a 404. Null if the path does not match. |
| 84 | */ | 84 | */ |
| 85 | readonly handlePausedRequest: (request: Request) => Promise<Response | null>; | 85 | readonly handlePausedRequest: ( |
| 86 | request: Request, | ||
| 87 | principal?: Principal, | ||
| 88 | ) => Promise<Response | null>; | ||
| 86 | /** | 89 | /** |
| 87 | * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — | 90 | * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — |
| 88 | * record the human's decision and wake the long-polling `resume` tool call. | 91 | * record the human's decision and wake the long-polling `resume` tool call. |
| 89 | * Null if the path does not match. | 92 | * Null if the path does not match. |
| 90 | */ | 93 | */ |
| 91 | readonly handleApprovalRequest: (request: Request) => Promise<Response | null>; | 94 | readonly handleApprovalRequest: ( |
| 95 | request: Request, | ||
| 96 | principal?: Principal, | ||
| 97 | ) => Promise<Response | null>; | ||
| 92 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ | 98 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ |
| 93 | readonly close: () => Promise<void>; | 99 | readonly close: () => Promise<void>; |
| 94 | } | 100 | } |
| @@ -246,6 +252,16 @@ export const makeInMemoryMcpSessionStore = ( · 151 unmodified lines | |||
| 246 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), | 252 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), |
| 247 | }; | 253 | }; |
| 248 | 254 | ||
| 255 | const ownerAccess = ( | ||
| 256 | sessionId: string, | ||
| 257 | principal: Principal | undefined, | ||
| 258 | ): "allowed" | "not-found" | "forbidden" => { | ||
| 259 | const owner = owners.get(sessionId); | ||
| 260 | if (!owner) return "not-found"; | ||
| 261 | if (principal && !principalOwns(owner, principal)) return "forbidden"; | ||
| 262 | return "allowed"; | ||
| 263 | }; | ||
| 264 | |||
| 249 | /** Resolve a paused execution from the session that owns it, for HTTP approval. */ | 265 | /** Resolve a paused execution from the session that owns it, for HTTP approval. */ |
| 250 | const pausedFromSession = ( | 266 | const pausedFromSession = ( |
| 251 | sessionId: string, | 267 | sessionId: string, |
| @@ -261,25 +277,35 @@ export const makeInMemoryMcpSessionStore = ( · 9 unmodified lines | |||
| 261 | ); | 277 | ); |
| 262 | }; | 278 | }; |
| 263 | 279 | ||
| 264 | const handlePausedRequest = async (request: Request): Promise<Response | null> => { | 280 | const handlePausedRequest = async ( |
| 281 | request: Request, | ||
| 282 | principal?: Principal, | ||
| 283 | ): Promise<Response | null> => { | ||
| 265 | const match = PAUSED_PATH.exec(new URL(request.url).pathname); | 284 | const match = PAUSED_PATH.exec(new URL(request.url).pathname); |
| 266 | if (!match) return null; | 285 | if (!match) return null; |
| 267 | if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); | 286 | if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); |
| 268 | const paused = await pausedFromSession( | 287 | const sessionId = decodeURIComponent(match[1]!); |
| 269 | decodeURIComponent(match[1]!), | 288 | const access = ownerAccess(sessionId, principal); |
| 270 | decodeURIComponent(match[2]!), | 289 | if (access === "forbidden") return json({ error: "Forbidden" }, 403); |
| 271 | ); | 290 | if (access === "not-found") return json({ error: "Paused execution not found" }, 404); |
| 291 | const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!)); | ||
| 272 | if (!paused) return json({ error: "Paused execution not found" }, 404); | 292 | if (!paused) return json({ error: "Paused execution not found" }, 404); |
| 273 | return json({ text: paused.text, structured: paused.structured }); | 293 | return json({ text: paused.text, structured: paused.structured }); |
| 274 | }; | 294 | }; |
| 275 | 295 | ||
| 276 | const handleApprovalRequest = async (request: Request): Promise<Response | null> => { | 296 | const handleApprovalRequest = async ( |
| 297 | request: Request, | ||
| 298 | principal?: Principal, | ||
| 299 | ): Promise<Response | null> => { | ||
| 277 | const match = RESUME_PATH.exec(new URL(request.url).pathname); | 300 | const match = RESUME_PATH.exec(new URL(request.url).pathname); |
| 278 | if (!match) return null; | 301 | if (!match) return null; |
| 279 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); | 302 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); |
| 280 | 303 | ||
| 281 | const sessionId = decodeURIComponent(match[1]!); | 304 | const sessionId = decodeURIComponent(match[1]!); |
| 282 | const executionId = decodeURIComponent(match[2]!); | 305 | const executionId = decodeURIComponent(match[2]!); |
| 306 | const access = ownerAccess(sessionId, principal); | ||
| 307 | if (access === "forbidden") return json({ error: "Forbidden" }, 403); | ||
| 308 | if (access === "not-found") return json({ error: "Paused execution not found" }, 404); | ||
| 283 | // The session must still hold the paused execution — guards stale ids and | 309 | // The session must still hold the paused execution — guards stale ids and |
| 284 | // confirms the execution belongs to this session before recording. | 310 | // confirms the execution belongs to this session before recording. |
| 285 | const paused = await pausedFromSession(sessionId, executionId); | 311 | const paused = await pausedFromSession(sessionId, executionId); |