/** * Production server for @executor-js/local. * * Serves the Vite-built SPA + Effect API + MCP server. * * Run directly: bun run apps/local/src/serve.ts * Or import: import { startServer } from "@executor-js/local/serve" */ import { resolve, join } from "node:path";import { readdirSync } from "node:fs";import type { Subprocess } from "bun";import { setOAuthCompletionListener } from "@executor-js/api";import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";import { loadOrMintLocalAuthToken } from "./auth";import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";import { disposeAnalytics } from "./analytics";import { startIntegrationsRefresh } from "./integrations";import { disposeServerHandlers, getServerHandlers } from "./main";import { DEFAULT_ALLOWED_HOSTS, hasFileExtension, isAllowedOrigin, isUnauthenticatedOAuthClientMetadataPath, isUnauthenticatedOAuthPath, makeIsAuthorized, normalizeCredential,} from "./serve-shared"; // ---------------------------------------------------------------------------// Static files// --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise<Response>; const htmlResponse = (file: Bun.BunFile): Response => new Response(file, { headers: { "content-type": "text/html", "cache-control": "no-store" }, }); const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => new Response( JSON.stringify( oauthClientIdMetadataDocumentFromRequest({ requestUrl, webRequest, mountPrefix: "/api", }), ), { headers: { "content-type": "application/json", "cache-control": "public, max-age=300", }, }, ); function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets try { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); const routePath = `${prefix}/${entry.name}`; if (entry.isDirectory()) { Object.assign(routes, collectStaticRoutes(fullPath, routePath)); } else { const file = Bun.file(fullPath); routes[routePath] = () => routePath === "/index.html" ? htmlResponse(file) : new Response(file, { headers: { "content-type": file.type || "application/octet-stream", }, }); } } } catch {} return routes;} /** * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes. * The embedded map is generated by the CLI build step using `with { type: "file" }`. */function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; for (const [key, bunfsPath] of Object.entries(embedded)) { const file = Bun.file(bunfsPath); routes[`/${key}`] = () => key === "index.html" ? htmlResponse(file) : new Response(file, { headers: { "content-type": file.type || "application/octet-stream", }, }); } return routes;} // ---------------------------------------------------------------------------// Dev mode: spawn vite as a child and proxy non-API requests to it//// Enabled when EXECUTOR_DEV=1. Lets `dev:cli` serve a fresh UI without// requiring a manual `bun run build` after every change. The daemon still// owns /api and /mcp — only SPA/asset requests get proxied.// --------------------------------------------------------------------------- interface ViteChild { readonly url: string; readonly stop: () => Promise<void>;} const viteChildSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; async function allocatePort(): Promise<number> { const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(), }); const port = probe.port ?? 0; probe.stop(true); return port;} async function startViteChild(): Promise<ViteChild> { const vitePort = await allocatePort(); const cwd = resolve(import.meta.dirname, ".."); const viteEntrypoint = resolve(cwd, "node_modules/vite/bin/vite.js"); const env = { ...process.env }; delete env.PORT; // Run Vite directly under Bun, matching the `dev:vite` script without a // bunx wrapper that can outlive its child. --strictPort keeps the URL stable. const child: Subprocess = Bun.spawn( [ process.execPath, viteEntrypoint, "dev", "--port", String(vitePort), "--strictPort", "--host", "127.0.0.1", ], { cwd, // EXECUTOR_DEV_VITE_PORT — vite.config reads this and points the // HMR client at vite's real port. The browser loads HTML through // the daemon proxy but opens the HMR WebSocket directly to vite, // sidestepping the daemon's lack of WS proxying. env: { ...env, EXECUTOR_DEV_VITE_PORT: String(vitePort), }, stdout: "inherit", stderr: "inherit", }, ); let stopping = false; const stop = async (): Promise<void> => { if (stopping) { await child.exited; return; } stopping = true; for (const signal of viteChildSignals) process.off(signal, stopOnParentSignal); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(5_000)]); if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; }; const stopOnParentSignal = (): void => { // A PTY/session teardown can signal the CLI while Vite is still optimizing // dependencies, before the server's normal stop handle exists. Reap the // owned child immediately; the CLI's signal waiter performs full cleanup // once startup has completed. void stop(); }; for (const signal of viteChildSignals) process.once(signal, stopOnParentSignal); const url = `http://127.0.0.1:${vitePort}`; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet try { const r = await fetch(`${url}/`, { redirect: "manual", // A listening socket is not proof that Vite can answer. Bound each // probe so one accepted-but-stalled request cannot defeat the 30s boot // deadline and wedge the entire local e2e suite. signal: AbortSignal.timeout(5_000), }); if (r.status < 500) { await r.body?.cancel(); return { url, stop, }; } await r.body?.cancel(); } catch { // not up yet } if (child.exitCode !== null) { await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); } await Bun.sleep(150); } await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable throw new Error(`vite dev did not become reachable on ${url} within 30s`);} async function proxyToVite(req: Request, viteUrl: string): Promise<Response> { const target = new URL(req.url); target.protocol = "http:"; target.host = new URL(viteUrl).host; // Strip hop-by-hop headers that confuse the upstream const headers = new Headers(req.headers); headers.delete("host"); headers.set("host", target.host); const hasBody = req.method !== "GET" && req.method !== "HEAD"; return fetch(target, { method: req.method, headers, body: hasBody ? req.body : undefined, redirect: "manual", // @ts-expect-error — Bun/undici extension required for streamed bodies duplex: hasBody ? "half" : undefined, });} // ---------------------------------------------------------------------------// Server// --------------------------------------------------------------------------- export interface StartServerOptions { port?: number; /** Directory containing built client assets (for dev/disk serving). */ clientDir?: string; /** Embedded web UI map from compiled binary (path → bunfs path). Overrides clientDir. */ embeddedWebUI?: Record<string, string> | null; /** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */ hostname?: string; /** * Extra origins granted credentialed CORS access, on top of localhost/ * 127.0.0.1 (any port). There is no Host allowlist — the bearer token is the * security boundary — so reaching the server from another host (e.g. over a * tailnet) needs nothing here; this only widens cross-origin CORS. */ allowedHosts?: ReadonlyArray<string>; /** * Bearer token required for every `/api` and `/mcp` request. Optional — when * omitted the stable token is loaded from (or minted into) `auth.json`. Pass * an explicit value to override (e.g. the desktop main process threads its * own token to the sidecar child). */ authToken?: string; /** Test hook for supplying API/MCP handlers without loading the local server graph. */ handlers?: ServerHandlers;} export interface ServerInstance { port: number; /** The effective bearer token this server validates. Callers publish it in the * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */ authToken: string; stop: () => Promise<void>;} type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; const corsHeaders = { "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3", "access-control-allow-credentials": "true", "access-control-expose-headers": "*",} as const; const withCorsHeaders = ( req: Request, response: Response, allowedHosts: ReadonlySet<string>,): Response => { const origin = req.headers.get("origin"); // Same-origin requests carry no Origin header — nothing to do. Cross-origin // requests only get credentialed CORS if their Origin is an allowed loopback // host; an arbitrary web page (e.g. https://evil.example) gets no ACAO, so // the browser blocks it reading the response even if it knew the token. if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); headers.set( "access-control-allow-headers", req.headers.get("access-control-request-headers") ?? corsHeaders["access-control-allow-headers"], ); headers.append("vary", "Origin"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, });}; const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response => withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => { await handlers.mcp.close(); await handlers.api.dispose();}; const ignoreCleanupFailure = async (cleanup: () => Promise<unknown>): Promise<void> => { // oxlint-disable-next-line executor/no-promise-catch -- boundary: startup cleanup is best-effort and must not mask the original startup failure await cleanup().catch(() => {});}; export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; // ONE credential, always present: an explicit override or the stable token // from auth.json (minted on first run). Auth is unconditionally on — loopback // is no longer a free pass, since Executor runs arbitrary code that any local // process could otherwise drive. const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); const isAuthorized = makeIsAuthorized(authToken); // CORS-only origin allowlist (no Host gate — the bearer is the boundary). const corsAllowedHosts = new Set<string>([ ...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? []), ]); const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist"); startIntegrationsRefresh(); const ownsHandlers = opts.handlers === undefined; const handlers = opts.handlers ?? (await getServerHandlers(authToken)); let viteChild: ViteChild | null = null; const disposeOwnedResources = async (): Promise<void> => { setOAuthCompletionListener(null); if (ownsHandlers) { await disposeServerHandlers(); } else { await closeProvidedHandlers(handlers); } // Final analytics flush; the layer finalizer drains the buffer. await disposeAnalytics(); if (viteChild) await viteChild.stop(); }; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: after handlers boot, failed static/dev/Bun startup must release DB ownership before surfacing the startup error try { // Mirror every OAuth callback completion into the local in-memory result // store. The Electron desktop renderer polls /api/oauth/await/:sessionId // for these when the user runs the flow in their system browser (no // shared origin → no postMessage). Cloud doesn't register a listener; // its same-origin web SPA receives results via postMessage directly. setOAuthCompletionListener((result) => publishOAuthResult(result)); // Build static routes from either embedded assets, disk, or a spawned // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and // disables the file-extension 404 short-circuit since vite serves // hashed asset paths directly. let staticRoutes: Record<string, StaticHandler> = {}; let serveIndex: StaticHandler; const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; if (devMode) { console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); viteChild = await startViteChild(); // Diagnostic only — this is the internal vite port the daemon proxies to. // It must NOT read as a destination: the URL to open is the `Open:` line the // CLI prints (the daemon port, with ?_token). Hitting the vite port directly // skips that bootstrap and lands on the auth gate. console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`); serveIndex = () => // Unused when viteChild is non-null; defined so the type checker // can keep `serveIndex` non-nullable. new Response("vite not ready", { status: 503 }); } else if (opts.embeddedWebUI) { staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); serveIndex = () => htmlResponse(indexFile); } else { staticRoutes = collectStaticRoutes(clientDir); const indexFile = Bun.file(join(clientDir, "index.html")); serveIndex = () => htmlResponse(indexFile); } const server = Bun.serve({ port, hostname, // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume // can idle longer during human approval; `0` disables the socket timeout. idleTimeout: 0, routes: { ...staticRoutes }, async fetch(req) { const withCors = (response: Response): Response => withCorsHeaders(req, response, corsAllowedHosts); if (req.method === "OPTIONS" && req.headers.has("origin")) { return corsPreflightResponse(req, corsAllowedHosts); } const url = new URL(req.url); // Unauthenticated liveness probe — carries no data, used by the CLI // reachability check (which therefore never forwards a credential). if (url.pathname === "/api/health" && req.method === "GET") { return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); } // OAuth callbacks and CIMD documents are reached by the external // provider, which cannot carry our local bearer. Everything else under // /api and /mcp requires the bearer. const skipAuth = isUnauthenticatedOAuthPath(url.pathname); const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath; if (isGatedSurface && !skipAuth && !isAuthorized(req)) { return withCors( new Response("Unauthorized", { status: 401, headers: { "www-authenticate": 'Bearer realm="executor"' }, }), ); } if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") { return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); } if (isMcpPath) { return withCors(await handlers.mcp.handleRequest(req)); } if (url.pathname.startsWith("/api/mcp-sessions/")) { // GET → paused-execution detail for the approval page; POST → record the // decision. Both are bearer-gated above. const handler = req.method === "GET" ? handlers.mcp.handlePausedRequest : handlers.mcp.handleApprovalRequest; return withCors(await handler(req)); } // OAuth result polling — local-only, served outside the typed API // because cloud (Cloudflare Workers, stateless) can't back the // in-memory store. See setOAuthCompletionListener above. const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); if (awaitMatch && req.method === "GET") { const result = consumeOAuthResult(awaitMatch[1]); return withCors( new Response(JSON.stringify(result), { headers: { "content-type": "application/json" }, }), ); } if (url.pathname.startsWith("/api/") || url.pathname === "/api") { url.pathname = url.pathname.slice("/api".length) || "/"; return withCors(await handlers.api.handler(new Request(url, req))); } // App-level routes the Effect app serves at root, OUTSIDE the `/api` // prefix the shell strips (e.g. `/v1/app/npm/dist-tags`, which the web // shell's update check fetches). Public, like `/api/health` above, so no // bearer gate. Forward verbatim; without this they'd fall through to the // vite/SPA fallback and answer 200-with-HTML, breaking the JSON parse. if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) { return withCors(await handlers.api.handler(new Request(url, req))); } // Dev mode: forward everything else (SPA + hashed assets) to the // vite child so source edits show up without a rebuild. if (viteChild) { return withCors(await proxyToVite(req, viteChild.url)); } // If a path looks like a static asset (has a file extension), do not // fall back to SPA HTML. Returning index.html here causes browser module // MIME errors when hashed chunks are stale/missing. if (hasFileExtension(url.pathname)) { return withCors(new Response("Not Found", { status: 404 })); } // SPA fallback (unauthenticated — the browser loads the shell, then reads // its token and sends the bearer on subsequent /api calls). return withCors(await serveIndex()); },Lines 1–500 of 528. Load more
/** * Production server for @executor-js/local. * * Serves the Vite-built SPA + Effect API + MCP server. * * Run directly: bun run apps/local/src/serve.ts * Or import: import { startServer } from "@executor-js/local/serve" */ import { resolve, join } from "node:path"; import { readdirSync } from "node:fs"; import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; import { loadOrMintLocalAuthToken } from "./auth"; import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; import { disposeAnalytics } from "./analytics"; import { startIntegrationsRefresh } from "./integrations"; import { disposeServerHandlers, getServerHandlers } from "./main"; import { DEFAULT_ALLOWED_HOSTS, hasFileExtension, isAllowedOrigin, isUnauthenticatedOAuthClientMetadataPath, isUnauthenticatedOAuthPath, makeIsAuthorized, normalizeCredential, } from "./serve-shared"; // --------------------------------------------------------------------------- // Static files // --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise<Response>; const htmlResponse = (file: Bun.BunFile): Response => new Response(file, { headers: { "content-type": "text/html", "cache-control": "no-store" }, }); const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => new Response( JSON.stringify( oauthClientIdMetadataDocumentFromRequest({ requestUrl, webRequest, mountPrefix: "/api", }), ), { headers: { "content-type": "application/json", "cache-control": "public, max-age=300", }, }, ); function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets try { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); const routePath = `${prefix}/${entry.name}`; if (entry.isDirectory()) { Object.assign(routes, collectStaticRoutes(fullPath, routePath)); } else { const file = Bun.file(fullPath); routes[routePath] = () => routePath === "/index.html" ? htmlResponse(file) : new Response(file, { headers: { "content-type": file.type || "application/octet-stream", }, }); } } } catch {} return routes; } /** * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes. * The embedded map is generated by the CLI build step using `with { type: "file" }`. */ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; for (const [key, bunfsPath] of Object.entries(embedded)) { const file = Bun.file(bunfsPath); routes[`/${key}`] = () => key === "index.html" ? htmlResponse(file) : new Response(file, { headers: { "content-type": file.type || "application/octet-stream", }, }); } return routes; } // --------------------------------------------------------------------------- // Dev mode: spawn vite as a child and proxy non-API requests to it // // Enabled when EXECUTOR_DEV=1. Lets `dev:cli` serve a fresh UI without // requiring a manual `bun run build` after every change. The daemon still // owns /api and /mcp — only SPA/asset requests get proxied. // --------------------------------------------------------------------------- interface ViteChild { readonly url: string; readonly stop: () => Promise<void>; } const viteChildSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; async function allocatePort(): Promise<number> { const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(), }); const port = probe.port ?? 0; probe.stop(true); return port; } async function startViteChild(): Promise<ViteChild> { const vitePort = await allocatePort(); const cwd = resolve(import.meta.dirname, ".."); const viteEntrypoint = resolve(cwd, "node_modules/vite/bin/vite.js"); const env = { ...process.env }; delete env.PORT; // Run Vite directly under Bun, matching the `dev:vite` script without a // bunx wrapper that can outlive its child. --strictPort keeps the URL stable. const child: Subprocess = Bun.spawn( [ process.execPath, viteEntrypoint, "dev", "--port", String(vitePort), "--strictPort", "--host", "127.0.0.1", ], { cwd, // EXECUTOR_DEV_VITE_PORT — vite.config reads this and points the // HMR client at vite's real port. The browser loads HTML through // the daemon proxy but opens the HMR WebSocket directly to vite, // sidestepping the daemon's lack of WS proxying. env: { ...env, EXECUTOR_DEV_VITE_PORT: String(vitePort), }, stdout: "inherit", stderr: "inherit", }, ); let stopping = false; const stop = async (): Promise<void> => { if (stopping) { await child.exited; return; } stopping = true; for (const signal of viteChildSignals) process.off(signal, stopOnParentSignal); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(5_000)]); if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; }; const stopOnParentSignal = (): void => { // A PTY/session teardown can signal the CLI while Vite is still optimizing // dependencies, before the server's normal stop handle exists. Reap the // owned child immediately; the CLI's signal waiter performs full cleanup // once startup has completed. void stop(); }; for (const signal of viteChildSignals) process.once(signal, stopOnParentSignal); const url = `http://127.0.0.1:${vitePort}`; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet try { const r = await fetch(`${url}/`, { redirect: "manual", // A listening socket is not proof that Vite can answer. Bound each // probe so one accepted-but-stalled request cannot defeat the 30s boot // deadline and wedge the entire local e2e suite. signal: AbortSignal.timeout(5_000), }); if (r.status < 500) { await r.body?.cancel(); return { url, stop, }; } await r.body?.cancel(); } catch { // not up yet } if (child.exitCode !== null) { await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); } await Bun.sleep(150); } await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable throw new Error(`vite dev did not become reachable on ${url} within 30s`); } async function proxyToVite(req: Request, viteUrl: string): Promise<Response> { const target = new URL(req.url); target.protocol = "http:"; target.host = new URL(viteUrl).host; // Strip hop-by-hop headers that confuse the upstream const headers = new Headers(req.headers); headers.delete("host"); headers.set("host", target.host); const hasBody = req.method !== "GET" && req.method !== "HEAD"; return fetch(target, { method: req.method, headers, body: hasBody ? req.body : undefined, redirect: "manual", // @ts-expect-error — Bun/undici extension required for streamed bodies duplex: hasBody ? "half" : undefined, }); } // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- export interface StartServerOptions { port?: number; /** Directory containing built client assets (for dev/disk serving). */ clientDir?: string; /** Embedded web UI map from compiled binary (path → bunfs path). Overrides clientDir. */ embeddedWebUI?: Record<string, string> | null; /** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */ hostname?: string; /** * Extra origins granted credentialed CORS access, on top of localhost/ * 127.0.0.1 (any port). There is no Host allowlist — the bearer token is the * security boundary — so reaching the server from another host (e.g. over a * tailnet) needs nothing here; this only widens cross-origin CORS. */ allowedHosts?: ReadonlyArray<string>; /** * Bearer token required for every `/api` and `/mcp` request. Optional — when * omitted the stable token is loaded from (or minted into) `auth.json`. Pass * an explicit value to override (e.g. the desktop main process threads its * own token to the sidecar child). */ authToken?: string; /** Test hook for supplying API/MCP handlers without loading the local server graph. */ handlers?: ServerHandlers; } export interface ServerInstance { port: number; /** The effective bearer token this server validates. Callers publish it in the * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */ authToken: string; stop: () => Promise<void>; } type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; const corsHeaders = { "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3", "access-control-allow-credentials": "true", "access-control-expose-headers": "*", } as const; const withCorsHeaders = ( req: Request, response: Response, allowedHosts: ReadonlySet<string>, ): Response => { const origin = req.headers.get("origin"); // Same-origin requests carry no Origin header — nothing to do. Cross-origin // requests only get credentialed CORS if their Origin is an allowed loopback // host; an arbitrary web page (e.g. https://evil.example) gets no ACAO, so // the browser blocks it reading the response even if it knew the token. if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); headers.set( "access-control-allow-headers", req.headers.get("access-control-request-headers") ?? corsHeaders["access-control-allow-headers"], ); headers.append("vary", "Origin"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); }; const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response => withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => { await handlers.mcp.close(); await handlers.api.dispose(); }; const ignoreCleanupFailure = async (cleanup: () => Promise<unknown>): Promise<void> => { // oxlint-disable-next-line executor/no-promise-catch -- boundary: startup cleanup is best-effort and must not mask the original startup failure await cleanup().catch(() => {}); }; export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; // ONE credential, always present: an explicit override or the stable token // from auth.json (minted on first run). Auth is unconditionally on — loopback // is no longer a free pass, since Executor runs arbitrary code that any local // process could otherwise drive. const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); const isAuthorized = makeIsAuthorized(authToken); // CORS-only origin allowlist (no Host gate — the bearer is the boundary). const corsAllowedHosts = new Set<string>([ ...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? []), ]); const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist"); startIntegrationsRefresh(); const ownsHandlers = opts.handlers === undefined; const handlers = opts.handlers ?? (await getServerHandlers(authToken)); let viteChild: ViteChild | null = null; const disposeOwnedResources = async (): Promise<void> => { setOAuthCompletionListener(null); if (ownsHandlers) { await disposeServerHandlers(); } else { await closeProvidedHandlers(handlers); } // Final analytics flush; the layer finalizer drains the buffer. await disposeAnalytics(); if (viteChild) await viteChild.stop(); }; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: after handlers boot, failed static/dev/Bun startup must release DB ownership before surfacing the startup error try { // Mirror every OAuth callback completion into the local in-memory result // store. The Electron desktop renderer polls /api/oauth/await/:sessionId // for these when the user runs the flow in their system browser (no // shared origin → no postMessage). Cloud doesn't register a listener; // its same-origin web SPA receives results via postMessage directly. setOAuthCompletionListener((result) => publishOAuthResult(result)); // Build static routes from either embedded assets, disk, or a spawned // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and // disables the file-extension 404 short-circuit since vite serves // hashed asset paths directly. let staticRoutes: Record<string, StaticHandler> = {}; let serveIndex: StaticHandler; const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; if (devMode) { console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); viteChild = await startViteChild(); // Diagnostic only — this is the internal vite port the daemon proxies to. // It must NOT read as a destination: the URL to open is the `Open:` line the // CLI prints (the daemon port, with ?_token). Hitting the vite port directly // skips that bootstrap and lands on the auth gate. console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`); serveIndex = () => // Unused when viteChild is non-null; defined so the type checker // can keep `serveIndex` non-nullable. new Response("vite not ready", { status: 503 }); } else if (opts.embeddedWebUI) { staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); serveIndex = () => htmlResponse(indexFile); } else { staticRoutes = collectStaticRoutes(clientDir); const indexFile = Bun.file(join(clientDir, "index.html")); serveIndex = () => htmlResponse(indexFile); } const server = Bun.serve({ port, hostname, // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume // can idle longer during human approval; `0` disables the socket timeout. idleTimeout: 0, routes: { ...staticRoutes }, async fetch(req) { const withCors = (response: Response): Response => withCorsHeaders(req, response, corsAllowedHosts); if (req.method === "OPTIONS" && req.headers.has("origin")) { return corsPreflightResponse(req, corsAllowedHosts); } const url = new URL(req.url); // Unauthenticated liveness probe — carries no data, used by the CLI // reachability check (which therefore never forwards a credential). if (url.pathname === "/api/health" && req.method === "GET") { return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); } // OAuth callbacks and CIMD documents are reached by the external // provider, which cannot carry our local bearer. Everything else under // /api and /mcp requires the bearer. const skipAuth = isUnauthenticatedOAuthPath(url.pathname); const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath; if (isGatedSurface && !skipAuth && !isAuthorized(req)) { return withCors( new Response("Unauthorized", { status: 401, headers: { "www-authenticate": 'Bearer realm="executor"' }, }), ); } if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") { return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); } if (isMcpPath) { return withCors(await handlers.mcp.handleRequest(req)); } if (url.pathname.startsWith("/api/mcp-sessions/")) { // GET → paused-execution detail for the approval page; POST → record the // decision. Both are bearer-gated above. const handler = req.method === "GET" ? handlers.mcp.handlePausedRequest : handlers.mcp.handleApprovalRequest; return withCors(await handler(req)); } // OAuth result polling — local-only, served outside the typed API // because cloud (Cloudflare Workers, stateless) can't back the // in-memory store. See setOAuthCompletionListener above. const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); if (awaitMatch && req.method === "GET") { const result = consumeOAuthResult(awaitMatch[1]); return withCors( new Response(JSON.stringify(result), { headers: { "content-type": "application/json" }, }), ); } if (url.pathname.startsWith("/api/") || url.pathname === "/api") { url.pathname = url.pathname.slice("/api".length) || "/"; return withCors(await handlers.api.handler(new Request(url, req))); } // App-level routes the Effect app serves at root, OUTSIDE the `/api` // prefix the shell strips (e.g. `/v1/app/npm/dist-tags`, which the web // shell's update check fetches). Public, like `/api/health` above, so no // bearer gate. Forward verbatim; without this they'd fall through to the // vite/SPA fallback and answer 200-with-HTML, breaking the JSON parse. if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) { return withCors(await handlers.api.handler(new Request(url, req))); } // Dev mode: forward everything else (SPA + hashed assets) to the // vite child so source edits show up without a rebuild. if (viteChild) { return withCors(await proxyToVite(req, viteChild.url)); } // If a path looks like a static asset (has a file extension), do not // fall back to SPA HTML. Returning index.html here causes browser module // MIME errors when hashed chunks are stale/missing. if (hasFileExtension(url.pathname)) { return withCors(new Response("Not Found", { status: 404 })); } // SPA fallback (unauthenticated — the browser loads the shell, then reads // its token and sends the bearer on subsequent /api calls). return withCors(await serveIndex()); },