History

Implement new database ownership process in cli and desktop sidecar

Griffin Evans committed 4 months ago

Commit 3403ccb1 parent 6a53d158 files · +605 −322Browse files

Unified · Split

8 files
  1. apps/cli/src/main.ts +175 −59
  2. apps/desktop/src/main/sidecar.ts +92 −40
  3. apps/local/src/db/data-dir-ownership.ts +9 −0
  4. apps/local/src/executor.ts +9 −26
  5. apps/local/src/index.ts +5 −0
  6. apps/local/src/main.ts +92 −51
  7. apps/local/src/serve.test.ts +50 −1
  8. apps/local/src/serve.ts +173 −145
apps/cli/src/main.ts+175−59
@@ -88,6 +88,9 @@ import { · 87 unmodified lines
8888 getExecutor,
8989 rotateLocalAuthToken,
9090 localAuthTokenPath,
91+ findDataDirOwnershipHeld,
92+ type ServerInstance,
93+ type StartServerOptions,
9194 } from "@executor-js/local";
9295 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
9396 import { fetchIntegrations } from "./integrations";
@@ -179,6 +182,8 @@ const DEFAULT_PORT = 4788; · 85 unmodified lines
179182 /** Canonical public docs (Mintlify), matching the web shell's DEFAULT_DOCS_URL. */
180183 const DOCS_URL = "https://executor.sh/docs";
181184 const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
185+const DESKTOP_SIDECAR_READY_SENTINEL = "EXECUTOR_READY";
186+const DESKTOP_SIDECAR_ATTACHED_SENTINEL = "EXECUTOR_ATTACHED";
182187 const DAEMON_BOOT_TIMEOUT_MS = 15_000;
183188 const DAEMON_BOOT_POLL_MS = 150;
184189 const DAEMON_STOP_TIMEOUT_MS = 10_000;
@@ -188,6 +193,8 @@ const SERVICE_BOOT_TIMEOUT_MS = 45_000; · 3 unmodified lines
188193 // Helpers
189194 // ---------------------------------------------------------------------------
190195
196+const shouldEmitDesktopSidecarSentinels = (): boolean => process.env.EXECUTOR_CLIENT === "desktop";
197+
191198 const waitForShutdownSignal = () =>
192199 Effect.callback<void, never>((resume) => {
193200 const shutdown = () => resume(Effect.void);
@@ -206,9 +213,9 @@ const waitForShutdownSignal = () => · 12 unmodified lines
206213 const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>
207214 isExecutorServerReachable({ baseUrl });
208215
209−const readActiveLocalServerManifest = (): Effect.Effect<
216+const readReachableLocalServerHint = (): Effect.Effect<
210217 ExecutorLocalServerManifest | null,
211− Error,
218+ never,
212219 FileSystem.FileSystem | PlatformPath.Path
213220 > =>
214221 Effect.gen(function* () {
@@ -224,17 +231,11 @@ const readActiveLocalServerManifest = (): Effect.Effect< · 9 unmodified lines
224231 return null;
225232 }
226233
227− return yield* Effect.fail(
228− new Error(
229− [
230− `A local Executor ${manifest.kind} is registered at ${manifest.connection.origin} (pid ${manifest.pid}) but is not reachable.`,
231− "Refusing to start another local server against the same data directory.",
232− "Stop the existing process or remove the stale server-control manifest after verifying the process is not using the database.",
233− ].join("\n"),
234− ),
235− );
234+ return null;
236235 });
237236
237+const readActiveLocalServerManifest = readReachableLocalServerHint;
238+
238239 const normalizeDaemonScopeDir = (dir: string): string => {
239240 const resolved = resolve(dir);
240241 return existsSync(resolved) ? realpathSync.native(resolved) : resolved;
@@ -265,6 +266,48 @@ interface ExecuteCodeResult { · 24 unmodified lines
265266 readonly outcome: ExecuteCodeOutcome;
266267 }
267268
269+type LocalServerStartResult =
270+ | { readonly kind: "started"; readonly server: ServerInstance }
271+ | { readonly kind: "attached"; readonly manifest: ExecutorLocalServerManifest };
272+
273+const attachToOwnedDataDirServerOrFail = (input: {
274+ readonly lockPath: string;
275+}): Effect.Effect<ExecutorLocalServerManifest, Error, FileSystem.FileSystem | PlatformPath.Path> =>
276+ Effect.gen(function* () {
277+ const manifest = yield* readReachableLocalServerHint();
278+ if (manifest) return manifest;
279+
280+ const path = yield* PlatformPath.Path;
281+ const dataDir = resolveExecutorDataDir(path);
282+ return yield* Effect.fail(
283+ new Error(
284+ [
285+ "Executor data directory is owned by another live process, but no reachable local server was advertised.",
286+ `Data directory: ${dataDir}`,
287+ `Ownership lock: ${input.lockPath}`,
288+ "Wait for the existing process to finish starting, or stop it and retry.",
289+ ].join("\n"),
290+ ),
291+ );
292+ });
293+
294+const startServerOrAttachOwnedDataDir = (
295+ options: StartServerOptions,
296+): Effect.Effect<LocalServerStartResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
297+ Effect.tryPromise({
298+ try: () => startServer(options),
299+ catch: (cause) => cause,
300+ }).pipe(
301+ Effect.map((server) => ({ kind: "started" as const, server })),
302+ Effect.catch((cause) => {
303+ const ownership = findDataDirOwnershipHeld(cause);
304+ if (!ownership) return Effect.fail(toError(cause));
305+ return attachToOwnedDataDirServerOrFail({ lockPath: ownership.lockPath }).pipe(
306+ Effect.map((manifest) => ({ kind: "attached" as const, manifest })),
307+ );
308+ }),
309+ );
310+
268311 const parseDaemonUrl = (baseUrl: string) =>
269312 Effect.try({
270313 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
@@ -420,6 +463,38 @@ const resolveDaemonTarget = (baseUrl: string) => · 149 unmodified lines
420463 };
421464 });
422465
466+const waitForDaemonStartupTarget = (input: {
467+ readonly requestedBaseUrl: string;
468+}): Effect.Effect<string | null, never, FileSystem.FileSystem | PlatformPath.Path> =>
469+ Effect.gen(function* () {
470+ let readyBaseUrl: string | null = null;
471+ let requestedFallbackBaseUrl: string | null = null;
472+ const ready = yield* waitForReachable({
473+ check: Effect.gen(function* () {
474+ // Prefer the manifest: it is written after the server has opened the
475+ // owned DB and started serving, and it carries the bearer token the next
476+ // API call needs. A bare health response on the requested URL is only a
477+ // last-ditch fallback; keep polling for the manifest so tool calls do
478+ // not race ahead without auth.
479+ const manifest = yield* readReachableLocalServerHint();
480+ if (manifest) {
481+ readyBaseUrl = manifest.connection.origin;
482+ return true;
483+ }
484+
485+ if (yield* isServerReachable(input.requestedBaseUrl)) {
486+ requestedFallbackBaseUrl = input.requestedBaseUrl;
487+ }
488+
489+ return false;
490+ }),
491+ timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
492+ intervalMs: DAEMON_BOOT_POLL_MS,
493+ });
494+
495+ return ready ? readyBaseUrl : requestedFallbackBaseUrl;
496+ });
497+
423498 // Serialize daemon startup behind a filesystem lock so concurrent CLI invocations don't
424499 // each spawn their own daemon. The post-lock pointer recheck catches the case where
425500 // another invocation finished bootstrapping while we were waiting for the lock.
@@ -476,25 +551,21 @@ const spawnAndWaitForDaemon = (input: { · 50 unmodified lines
476551 env: process.env,
477552 });
478553
479− const ready = yield* waitForReachable({
480− check: isServerReachable(startBaseUrl),
481− timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
482− intervalMs: DAEMON_BOOT_POLL_MS,
483− });
554+ const readyBaseUrl = yield* waitForDaemonStartupTarget({ requestedBaseUrl: startBaseUrl });
484555
485− if (!ready) {
556+ if (!readyBaseUrl) {
486557 yield* terminateSpawnedDetachedProcess(child).pipe(Effect.ignore);
487558 return yield* Effect.fail(
488559 new Error(
489560 [
490− `Daemon did not become reachable at ${startBaseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,
561+ `Daemon did not become reachable at ${startBaseUrl} and no reachable local server manifest appeared within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,
491562 `Run in foreground to inspect logs: ${cliPrefix} daemon run --foreground --port ${selectedPort} --hostname ${input.host}`,
492563 ].join("\n"),
493564 ),
494565 );
495566 }
496567
497− return startBaseUrl;
568+ return readyBaseUrl;
498569 } finally {
499570 yield* releaseDaemonStartLock(lock).pipe(Effect.ignore);
500571 }
@@ -950,15 +1021,18 @@ const runForegroundSession = (input: { · 449 unmodified lines
9501021
9511022 try {
9521023 yield* assertNoOtherActiveLocalServer();
953− server = yield* Effect.promise(() =>
954− startServer({
955− port: input.port,
956− hostname: input.hostname,
957− allowedHosts: input.allowedHosts,
958− authToken: input.authToken,
959− embeddedWebUI,
960− }),
961− );
1024+ const startResult = yield* startServerOrAttachOwnedDataDir({
1025+ port: input.port,
1026+ hostname: input.hostname,
1027+ allowedHosts: input.allowedHosts,
1028+ authToken: input.authToken,
1029+ embeddedWebUI,
1030+ });
1031+ if (startResult.kind === "attached") {
1032+ console.log(`Executor is already running at ${startResult.manifest.connection.origin}.`);
1033+ return;
1034+ }
1035+ server = startResult.server;
9621036 baseUrl = `http://${displayHost}:${server.port}`;
9631037 yield* publishLocalServerManifest({
9641038 kind: "foreground",
@@ -1074,15 +1148,21 @@ const runDaemonSession = (input: { · 109 unmodified lines
10741148 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
10751149 }
10761150
1077− server = yield* Effect.promise(() =>
1078− startServer({
1079− port: input.port,
1080− hostname: input.hostname,
1081− allowedHosts: input.allowedHosts,
1082− authToken: input.authToken,
1083− embeddedWebUI,
1084− }),
1085− );
1151+ const startResult = yield* startServerOrAttachOwnedDataDir({
1152+ port: input.port,
1153+ hostname: input.hostname,
1154+ allowedHosts: input.allowedHosts,
1155+ authToken: input.authToken,
1156+ embeddedWebUI,
1157+ });
1158+ if (startResult.kind === "attached") {
1159+ if (shouldEmitDesktopSidecarSentinels()) {
1160+ console.log(DESKTOP_SIDECAR_ATTACHED_SENTINEL);
1161+ }
1162+ console.log(`Daemon already running at ${startResult.manifest.connection.origin}.`);
1163+ return;
1164+ }
1165+ server = startResult.server;
10861166
10871167 daemonPort = server.port;
10881168 token = randomUUID();
@@ -1120,6 +1200,9 @@ const runDaemonSession = (input: { · 31 unmodified lines
11201200 token,
11211201 });
11221202
1203+ if (shouldEmitDesktopSidecarSentinels()) {
1204+ console.log(`${DESKTOP_SIDECAR_READY_SENTINEL}:${daemonPort}`);
1205+ }
11231206 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);
11241207
11251208 yield* waitForShutdownSignal();
@@ -1216,28 +1299,61 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 90 unmodified lines
12161299
12171300 try {
12181301 yield* assertNoOtherActiveLocalServer();
1219− web = yield* Effect.promise(() =>
1220− withStdoutReroutedToStderr(async () => {
1221− const host = "127.0.0.1";
1222− const port = await Effect.runPromise(
1223− chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
1224− );
1225− const baseUrl = `http://localhost:${port}`;
1226− const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
1227−
1228− try {
1229− const executor = await getExecutor();
1230− const server = await startServer({
1231− port,
1232− hostname: host,
1233− embeddedWebUI,
1234− });
1235− const serverBaseUrl = `http://localhost:${server.port}`;
1236− return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
1237− } catch (cause) {
1238− restoreWebBaseUrl();
1239− throw cause;
1240− }
1302+ web = yield* Effect.tryPromise({
1303+ try: () =>
1304+ withStdoutReroutedToStderr(async () => {
1305+ const host = "127.0.0.1";
1306+ const port = await Effect.runPromise(
1307+ chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
1308+ );
1309+ const baseUrl = `http://localhost:${port}`;
1310+ const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
1311+
1312+ try {
1313+ const executor = await getExecutor();
1314+ const server = await startServer({
1315+ port,
1316+ hostname: host,
1317+ embeddedWebUI,
1318+ });
1319+ const serverBaseUrl = `http://localhost:${server.port}`;
1320+ return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
1321+ } catch (cause) {
1322+ restoreWebBaseUrl();
1323+ throw cause;
1324+ }
1325+ }),
1326+ catch: (cause) => cause,
1327+ }).pipe(
1328+ Effect.catch((cause) => {
1329+ const ownership = findDataDirOwnershipHeld(cause);
1330+ if (!ownership) return Effect.fail(toError(cause));
1331+ return Effect.gen(function* () {
1332+ const manifest = yield* readReachableLocalServerHint();
1333+ const path = yield* PlatformPath.Path;
1334+ const dataDir = resolveExecutorDataDir(path);
1335+ if (manifest) {
1336+ return yield* Effect.fail(
1337+ new Error(
1338+ [
1339+ `A local Executor server already owns ${dataDir} at ${manifest.connection.origin}.`,
1340+ "The stdio MCP server needs exclusive access to the local database.",
1341+ `Use the running HTTP MCP endpoint instead: ${manifest.connection.origin}/mcp`,
1342+ ].join("\n"),
1343+ ),
1344+ );
1345+ }
1346+ return yield* Effect.fail(
1347+ new Error(
1348+ [
1349+ "Executor data directory is owned by another live process, but no reachable local server was advertised.",
1350+ `Data directory: ${dataDir}`,
1351+ `Ownership lock: ${ownership.lockPath}`,
1352+ "Wait for the existing process to finish starting, or stop it and retry.",
1353+ ].join("\n"),
1354+ ),
1355+ );
1356+ });
12411357 }),
12421358 );
12431359 yield* publishLocalServerManifest({
apps/desktop/src/main/sidecar.ts+92−40
@@ -4,9 +4,10 @@ · 3 unmodified lines
44 * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`.
55 * In prod: spawns the bundled CLI binary in foreground daemon mode.
66 *
7− * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN
8− * The dev sidecar announces `EXECUTOR_READY:<port>`. The packaged CLI daemon
9− * announces `Daemon ready on http://host:port`. This controller accepts both.
7+ * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN.
8+ * The dev sidecar and packaged CLI child announce structured stdout sentinels
9+ * (`EXECUTOR_READY:<port>` or `EXECUTOR_ATTACHED`). Human-readable log text is
10+ * never part of the desktop startup contract.
1011 */
1112
1213 import { spawn, type ChildProcess } from "node:child_process";
@@ -35,6 +36,8 @@ const sidecarLog = log.scope("sidecar"); · 22 unmodified lines
3536 // Rolling stderr tail attached to crash reports. Bounded so a chatty
3637 // sidecar can't grow it unbounded over a long session.
3738 const STDERR_TAIL_LIMIT = 8 * 1024;
39+const READY_SENTINEL = "EXECUTOR_READY";
40+const ATTACHED_SENTINEL = "EXECUTOR_ATTACHED";
3841
3942 // Children deliberately stopped via stopSidecar (quit, restart, update) —
4043 // their exits are expected and must not be reported as crashes.
@@ -134,23 +137,6 @@ const removeManifestIfOwnedBy = (dataDir: string, pid: number) => { · 93 unmodified lines
134137 rmSync(localServerManifestPath(dataDir), { force: true });
135138 };
136139
137−const assertNoOtherLocalServerOwner = (dataDir: string) => {
138− const manifest = readManifest(dataDir);
139− if (!manifest) return;
140− if (!isPidAlive(manifest.pid)) {
141− removeManifestIfOwnedBy(dataDir, manifest.pid);
142− return;
143− }
144− // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process
145− throw new Error(
146− [
147− `A local Executor ${manifest.kind} is already running at ${manifest.connection.origin} (pid ${manifest.pid}).`,
148− `It owns the current data directory: ${manifest.dataDir}`,
149− "Stop it before starting the desktop sidecar.",
150− ].join("\n"),
151− );
152−};
153−
154140 const readLockPid = (dataDir: string): number | null => {
155141 const path = localServerStartLockPath(dataDir);
156142 if (!existsSync(path)) return null;
@@ -277,6 +263,12 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 120 unmodified lines
277263 const dataDir = scopeDir;
278264 mkdirSync(dataDir, { recursive: true });
279265
266+ // Fast path: a reachable local server already owns this data dir (a CLI
267+ // daemon, another desktop sidecar, or a supervised service). Attach to it
268+ // instead of spawning a child that would only lose the DB ownership race.
269+ const attached = await attachToReachableLocalServer(dataDir);
270+ if (attached) return attached;
271+
280272 // The stable bearer token from auth.json (shared with the CLI). The main
281273 // process holds it so it can inject the header into the webview; the child
282274 // validates against the same value. Always present — auth is unconditional.
@@ -304,13 +296,10 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 21 unmodified lines
304296 };
305297
306298 if (!cliManagedManifest) {
307− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures
308− try {
309− assertNoOtherLocalServerOwner(dataDir);
310− } catch (error) {
299+ const attachedAfterLock = await attachToReachableLocalServer(dataDir);
300+ if (attachedAfterLock) {
311301 releaseLock();
312− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock
313− throw error;
302+ return attachedAfterLock;
314303 }
315304 }
316305
@@ -348,6 +337,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 31 unmodified lines
348337
349338 return new Promise<SidecarConnection>((resolveStart, rejectStart) => {
350339 let stderrBuffer = "";
340+ let stdoutControlBuffer = "";
351341 let resolved = false;
352342 let rejected = false;
353343
@@ -362,14 +352,33 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 8 unmodified lines
362352 rejectStart(err);
363353 };
364354
355+ const resolveAttachedOwner = async (): Promise<boolean> => {
356+ if (resolved || rejected) return true;
357+ const attached = await attachToReachableLocalServer(dataDir);
358+ if (!attached) return false;
359+ resolved = true;
360+ expectedExits.add(child);
361+ releaseLock();
362+ resolveStart(attached);
363+ return true;
364+ };
365+
365366 const onStdout = (chunk: Buffer) => {
366367 const text = chunk.toString("utf8");
367368 process.stdout.write(`[executor-server] ${text}`);
368369 logStdoutLine(text);
369− const match =
370− text.match(/EXECUTOR_READY:(\d+)/) ??
371− text.match(/Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/);
372− if (match && !resolved) {
370+ stdoutControlBuffer += text;
371+ const rawControlLines = stdoutControlBuffer.split(/\r?\n/);
372+ stdoutControlBuffer = rawControlLines.pop() ?? "";
373+ const stdoutLines = rawControlLines
374+ .map((line) => line.trim())
375+ .filter((line) => line.length > 0);
376+ const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`));
377+ if (stdoutLines.includes(ATTACHED_SENTINEL) && !resolved) {
378+ void resolveAttachedOwner();
379+ return;
380+ }
381+ if (readyLine && !resolved) {
373382 if (!child.pid) {
374383 reject(
375384 // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar startup failure surfaces here as a rejected start promise
@@ -378,7 +387,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 2 unmodified lines
378387 return;
379388 }
380389 resolved = true;
381− const port = parseInt(match[1], 10);
390+ const port = parseInt(readyLine.slice(`${READY_SENTINEL}:`.length), 10);
382391 const baseUrl = `http://${hostname}:${port}`;
383392 if (!cliManagedManifest) {
384393 writeSidecarManifest({
@@ -428,15 +437,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 43 unmodified lines
428437 return;
429438 }
430439 if (rejected) return;
431− // Detect bind failure — the Node listener prints either "EADDRINUSE" or
432− // "address already in use" on stderr before exiting non-zero.
433− if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) {
434− reject(new SidecarPortInUseError(settings.port));
435− return;
436− }
437− const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`;
438− // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise
439− reject(new Error(message));
440+ void (async () => {
441+ if (await resolveAttachedOwner()) return;
442+ // Detect bind failure — the Node listener prints either "EADDRINUSE" or
443+ // "address already in use" on stderr before exiting non-zero.
444+ if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) {
445+ reject(new SidecarPortInUseError(settings.port));
446+ return;
447+ }
448+ const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`;
449+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise
450+ reject(new Error(message));
451+ })();
440452 };
441453
442454 child.stdout?.on("data", onStdout);
@@ -468,6 +480,46 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 25 unmodified lines
468480 return false;
469481 };
470482
483+const attachToReachableLocalServer = async (
484+ dataDir: string,
485+ options: { readonly requireCliDaemon?: boolean } = {},
486+): Promise<SidecarConnection | null> => {
487+ const manifest = readManifest(dataDir);
488+ if (!manifest) return null;
489+ if (options.requireCliDaemon && manifest.kind !== "cli-daemon") return null;
490+ if (!isPidAlive(manifest.pid)) {
491+ removeManifestIfOwnedBy(dataDir, manifest.pid);
492+ return null;
493+ }
494+
495+ const origin = manifest.connection.origin;
496+ const auth = manifest.connection.auth;
497+ const authToken = auth && auth.kind === "bearer" ? auth.token : "";
498+ if (!(await isDaemonReachable(origin))) {
499+ sidecarLog.warn(
500+ `local Executor ${manifest.kind} at ${origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`,
501+ );
502+ return null;
503+ }
504+
505+ const url = new URL(origin);
506+ sidecarLog.info(
507+ `attaching to local Executor ${manifest.kind} at ${origin} (pid ${manifest.pid})`,
508+ );
509+ return {
510+ baseUrl: origin,
511+ hostname: url.hostname,
512+ port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
513+ username: SERVER_SETTINGS_USERNAME,
514+ authToken,
515+ child: null,
516+ supervisedDaemon: manifest.kind === "cli-daemon",
517+ ownerVersion: manifest.owner.version,
518+ ownerClient: manifest.owner.client,
519+ ownerExecutablePath: manifest.owner.executablePath,
520+ };
521+};
522+
471523 /**
472524 * Attach to an already-running OS-supervised daemon instead of spawning our own
473525 * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a
apps/local/src/db/data-dir-ownership.ts+9−0
@@ -64,6 +64,15 @@ const configureOwnershipLockClient = async (client: Client): Promise<void> => { · 63 unmodified lines
6464 await client.execute("PRAGMA journal_mode = DELETE");
6565 };
6666
67+export const findDataDirOwnershipHeld = (cause: unknown): DataDirOwnershipHeld | null => {
68+ if (cause instanceof DataDirOwnershipHeld) return cause;
69+ if (!isRecord(cause)) return null;
70+
71+ const nestedCause = cause.cause;
72+ if (nestedCause === undefined || nestedCause === cause) return null;
73+ return findDataDirOwnershipHeld(nestedCause);
74+};
75+
6776 export const acquireDataDirOwnership = async (dataDir: string): Promise<DataDirOwnership> => {
6877 const lockPath = ownerLockPath(dataDir);
6978 const client = openOwnershipLockClient(lockPath);
apps/local/src/executor.ts+9−26
@@ -18,12 +18,10 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 17 unmodified lines
1818
1919 import executorConfig from "../executor.config";
2020 import { localDataMigrations } from "./db/data-migrations";
21−import { createSqliteFumaDb } from "./db/sqlite-fumadb";
22−import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";
21+import { openOwnedLocalDatabase } from "./db/owned-database";
2322
2423 interface ResolvedStorage {
2524 readonly dataDir: string;
26− readonly sqlitePath: string;
2725 }
2826
2927 const localNamespace = "executor_local";
@@ -36,10 +34,7 @@ const LOCAL_SUBJECT = "local"; · 6 unmodified lines
3634 const resolveStorage = (): ResolvedStorage => {
3735 const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");
3836 fs.mkdirSync(dataDir, { recursive: true });
39− return {
40− dataDir,
41− sqlitePath: join(dataDir, "data.db"),
42− };
37+ return { dataDir };
4338 };
4439
4540 // Hash suffix disambiguates same-basename folders so two projects with
@@ -151,28 +146,14 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 105 unmodified lines
151146 const tenantId = makeTenantId(cwd);
152147 const tables = collectTables();
153148
154− const migration = yield* Effect.tryPromise({
155− try: () =>
156− migrateLocalV1ToV2IfNeeded({
157− sqlitePath: storage.sqlitePath,
158− tables,
159− namespace: localNamespace,
160− tenantId,
161− }),
162− catch: (cause) =>
163− new LocalExecutorCreateError({
164− message: CREATE_SQLITE_ERROR_MESSAGE,
165− cause,
166− }),
167− });
168−
169− const sqlite = yield* Effect.acquireRelease(
149+ const owned = yield* Effect.acquireRelease(
170150 Effect.tryPromise({
171151 try: () =>
172− createSqliteFumaDb({
152+ openOwnedLocalDatabase({
153+ dataDir: storage.dataDir,
173154 tables,
174155 namespace: localNamespace,
175− path: storage.sqlitePath,
156+ tenantId,
176157 }),
177158 catch: (cause) =>
178159 new LocalExecutorCreateError({
@@ -180,8 +161,10 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 1 unmodified line
180161 cause,
181162 }),
182163 }),
183− (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),
164+ (database) => Effect.promise(() => database.close()).pipe(Effect.ignore),
184165 );
166+ const sqlite = owned.db;
167+ const migration = owned.migration;
185168
186169 // Boot-time data migrations: each registry entry runs once and is
187170 // stamped in the `data_migration` ledger; stamped entries are skipped
apps/local/src/index.ts+5−0
@@ -14,4 +14,9 @@ export { · 13 unmodified lines
1414 } from "./executor";
1515 export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp";
1616 export { startServer, type StartServerOptions, type ServerInstance } from "./serve";
17+export {
18+ DataDirOwnershipHeld,
19+ findDataDirOwnershipHeld,
20+ type DataDirOwnership,
21+} from "./db/data-dir-ownership";
1722 export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth";
apps/local/src/main.ts+92−51
@@ -1,9 +1,9 @@
1−import { Context, Effect, Layer, ManagedRuntime } from "effect";
1+import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";
22
33 import { createExecutionEngine } from "@executor-js/execution";
44 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
55 import { makeLocalApiHandler } from "./app";
6−import { createExecutorHandle, getExecutorBundle } from "./executor";
6+import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";
77 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
88
99 // ---------------------------------------------------------------------------
@@ -35,57 +35,96 @@ export type ServerHandlers = { · 25 unmodified lines
3535 readonly mcp: McpRequestHandler;
3636 };
3737
38+class ServerHandlersDisposeError extends Data.TaggedError("ServerHandlersDisposeError")<{
39+ readonly operation: "api.dispose" | "mcp.close" | "disposeExecutor" | "runtime.dispose";
40+ readonly cause: unknown;
41+}> {}
42+
43+const ignoreDisposeFailure = (
44+ operation: ServerHandlersDisposeError["operation"],
45+ dispose: () => Promise<unknown>,
46+) =>
47+ Effect.tryPromise({
48+ try: dispose,
49+ catch: (cause) => new ServerHandlersDisposeError({ operation, cause }),
50+ }).pipe(Effect.ignore);
51+
3852 const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => {
3953 await Effect.runPromise(
40− Effect.all(
41− [
42− Effect.tryPromise({
43− try: () => handlers.api.dispose(),
44− catch: (cause) => cause,
45− }).pipe(Effect.ignore),
46− Effect.tryPromise({
47− try: () => handlers.mcp.close(),
48− catch: (cause) => cause,
49− }).pipe(Effect.ignore),
50− ],
51− { concurrency: "unbounded" },
52− ),
54+ Effect.gen(function* () {
55+ yield* Effect.all(
56+ [
57+ ignoreDisposeFailure("api.dispose", () => handlers.api.dispose()),
58+ ignoreDisposeFailure("mcp.close", () => handlers.mcp.close()),
59+ ],
60+ { concurrency: "unbounded" },
61+ );
62+ // The API/MCP handlers borrow the shared local executor handle. Release it
63+ // after the surfaces are closed so server shutdown (and failed startup
64+ // cleanup via disposeServerHandlers) releases the owned data-dir lock.
65+ yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
66+ }),
5367 );
5468 };
5569
5670 export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {
57− // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
58− // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
59− const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);
60−
61− // The in-process MCP server runs over the SAME boot executor, with its own
62− // engine instance (the browser-approval + stdio surface is local-only and not
63− // part of the shared API). Reuse the shared boot bundle so the MCP executor is
64− // byte-identical to the one the API serves.
65− const { executor } = await getExecutorBundle();
66− const engine = createExecutionEngine({
67− executor,
68− codeExecutor: makeQuickJsExecutor(),
69− });
70− const mcp = createMcpRequestHandler({
71− defaultConfig: { engine },
72− createConfigForResource: async (resource) => {
73− if (resource.kind === "default") return { config: { engine } };
74− const handle = await createExecutorHandle({
75− activeToolkitSlug: resource.slug,
76− });
77− const toolkitEngine = createExecutionEngine({
78− executor: handle.executor,
79− codeExecutor: makeQuickJsExecutor(),
80− });
81− return {
82− config: { engine: toolkitEngine },
83− close: handle.dispose,
84− };
85− },
86− });
87−
88− return { api: apiHandler, mcp };
71+ let apiHandler: ServerHandlers["api"] | null = null;
72+ let mcp: McpRequestHandler | null = null;
73+
74+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: handler boot owns the shared executor; partial startup failure must release it before rethrowing
75+ try {
76+ // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
77+ // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
78+ apiHandler = await makeLocalApiHandler(token);
79+
80+ // The in-process MCP server runs over the SAME boot executor, with its own
81+ // engine instance (the browser-approval + stdio surface is local-only and not
82+ // part of the shared API). Reuse the shared boot bundle so the MCP executor is
83+ // byte-identical to the one the API serves.
84+ const { executor } = await getExecutorBundle();
85+ const engine = createExecutionEngine({
86+ executor,
87+ codeExecutor: makeQuickJsExecutor(),
88+ });
89+ mcp = createMcpRequestHandler({
90+ defaultConfig: { engine },
91+ createConfigForResource: async (resource) => {
92+ if (resource.kind === "default") return { config: { engine } };
93+ const handle = await createExecutorHandle({
94+ activeToolkitSlug: resource.slug,
95+ });
96+ const toolkitEngine = createExecutionEngine({
97+ executor: handle.executor,
98+ codeExecutor: makeQuickJsExecutor(),
99+ });
100+ return {
101+ config: { engine: toolkitEngine },
102+ close: handle.dispose,
103+ };
104+ },
105+ });
106+
107+ return { api: apiHandler, mcp };
108+ } catch (cause) {
109+ const partialApiHandler = apiHandler;
110+ const partialMcp = mcp;
111+ await Effect.runPromise(
112+ Effect.gen(function* () {
113+ yield* Effect.all(
114+ [
115+ partialApiHandler
116+ ? ignoreDisposeFailure("api.dispose", () => partialApiHandler.dispose())
117+ : Effect.void,
118+ partialMcp ? ignoreDisposeFailure("mcp.close", () => partialMcp.close()) : Effect.void,
119+ ],
120+ { concurrency: "unbounded" },
121+ );
122+ yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
123+ }),
124+ );
125+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async factory must reject with the original handler boot failure after cleanup
126+ throw cause;
127+ }
89128 };
90129
91130 export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(
@@ -120,9 +159,11 @@ export const disposeServerHandlers = async (): Promise<void> => { · 28 unmodified lines
120159 if (!runtime) return;
121160 serverHandlersRuntime = null;
122161 await Effect.runPromise(
123− Effect.tryPromise({
124− try: () => runtime.dispose(),
125− catch: (cause) => cause,
126− }).pipe(Effect.ignore),
162+ Effect.gen(function* () {
163+ yield* ignoreDisposeFailure("runtime.dispose", () => runtime.dispose());
164+ // Belt-and-suspenders: runtime disposal normally reaches closeServerHandlers,
165+ // but a failed runtime finalizer must not leave the shared DB owner alive.
166+ yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
167+ }),
127168 );
128169 };
apps/local/src/serve.test.ts+50−1
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55
6+import { acquireDataDirOwnership } from "./db/data-dir-ownership";
67 import { startServer, type ServerInstance } from "./serve";
78
89 let clientDir: string;
@@ -40,8 +41,9 @@ const startTestServer = async ( · 31 unmodified lines
4041 beforeEach(() => {
4142 clientDir = mkdtempSync(join(tmpdir(), "exec-local-serve-"));
4243 dataDir = mkdtempSync(join(tmpdir(), "exec-local-data-"));
43− // Isolate auth.json writes from the real ~/.executor.
44+ // Isolate auth.json writes and dynamic plugin lookup from the real ~/.executor/workspace.
4445 process.env.EXECUTOR_DATA_DIR = dataDir;
46+ process.env.EXECUTOR_SCOPE_DIR = dataDir;
4547 mkdirSync(join(clientDir, "assets"), { recursive: true });
4648 writeFileSync(
4749 join(clientDir, "index.html"),
@@ -56,6 +58,7 @@ afterEach(async () => { · 8 unmodified lines
5658 server = null;
5759 }
5860 delete process.env.EXECUTOR_DATA_DIR;
61+ delete process.env.EXECUTOR_SCOPE_DIR;
5962 rmSync(clientDir, { recursive: true, force: true });
6063 rmSync(dataDir, { recursive: true, force: true });
6164 });
@@ -81,6 +84,52 @@ describe("startServer static/SPA routing (unauthenticated)", () => { · 19 unmodified lines
8184 });
8285 });
8386
87+describe("startServer startup cleanup", () => {
88+ it("releases the owned DB when a default-handler server stops", async () => {
89+ server = await startServer({ port: 0, clientDir, authToken: TOKEN });
90+ await server.stop();
91+ server = null;
92+
93+ const ownership = await acquireDataDirOwnership(dataDir);
94+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: release the proof lock even if the assertion fails
95+ try {
96+ expect(ownership.lockPath).toContain("data.db.owner-lock");
97+ } finally {
98+ await ownership.release();
99+ }
100+ });
101+
102+ it("releases the owned DB if Bun.serve fails after handler boot", async () => {
103+ const blocker = Bun.serve({
104+ port: 0,
105+ hostname: "127.0.0.1",
106+ fetch: () => new Response("busy"),
107+ });
108+
109+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: keep the busy-port blocker alive only for this assertion and always stop it
110+ try {
111+ await expect(
112+ startServer({
113+ port: blocker.port,
114+ hostname: "127.0.0.1",
115+ clientDir,
116+ authToken: TOKEN,
117+ }),
118+ ).rejects.toThrow();
119+
120+ const ownership = await acquireDataDirOwnership(dataDir);
121+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: release the proof lock even if the assertion fails
122+ try {
123+ expect(ownership.lockPath).toContain("data.db.owner-lock");
124+ } finally {
125+ await ownership.release();
126+ }
127+ } finally {
128+ blocker.stop(true);
129+ }
130+ });
131+});
132+
84133 describe("startServer bearer auth", () => {
85134 it("mints and persists a 0600 auth.json when no token is supplied", async () => {
86135 server = await startServer({ port: 0, clientDir, handlers: testHandlers() });
apps/local/src/serve.ts+173−145
@@ -15,7 +15,7 @@ import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/serve · 14 unmodified lines
1515 import { loadOrMintLocalAuthToken } from "./auth";
1616 import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
1717 import { startIntegrationsRefresh } from "./integrations";
18−import { getServerHandlers } from "./main";
18+import { disposeServerHandlers, getServerHandlers } from "./main";
1919 import {
2020 DEFAULT_ALLOWED_HOSTS,
2121 hasFileExtension,
@@ -285,6 +285,16 @@ const withCorsHeaders = ( · 263 unmodified lines
285285 const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>
286286 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);
287287
288+const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => {
289+ await handlers.mcp.close();
290+ await handlers.api.dispose();
291+};
292+
293+const ignoreCleanupFailure = async (cleanup: () => Promise<unknown>): Promise<void> => {
294+ // oxlint-disable-next-line executor/no-promise-catch -- boundary: startup cleanup is best-effort and must not mask the original startup failure
295+ await cleanup().catch(() => {});
296+};
297+
288298 export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
289299 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
290300 const hostname = opts.hostname ?? "127.0.0.1";
@@ -303,155 +313,173 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 12 unmodified lines
303313
304314 startIntegrationsRefresh();
305315
316+ const ownsHandlers = opts.handlers === undefined;
306317 const handlers = opts.handlers ?? (await getServerHandlers(authToken));
307−
308− // Mirror every OAuth callback completion into the local in-memory result
309− // store. The Electron desktop renderer polls /api/oauth/await/:sessionId
310− // for these when the user runs the flow in their system browser (no
311− // shared origin → no postMessage). Cloud doesn't register a listener;
312− // its same-origin web SPA receives results via postMessage directly.
313− setOAuthCompletionListener((result) => publishOAuthResult(result));
314−
315− // Build static routes from either embedded assets, disk, or a spawned
316− // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and
317− // disables the file-extension 404 short-circuit since vite serves
318− // hashed asset paths directly.
319− let staticRoutes: Record<string, StaticHandler> = {};
320− let serveIndex: StaticHandler;
321318 let viteChild: ViteChild | null = null;
322319
323− const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
324− if (devMode) {
325− console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");
326− viteChild = await startViteChild();
327− // Diagnostic only — this is the internal vite port the daemon proxies to.
328− // It must NOT read as a destination: the URL to open is the `Open:` line the
329− // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
330− // skips that bootstrap and lands on the auth gate.
331− console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);
332− serveIndex = () =>
333− // Unused when viteChild is non-null; defined so the type checker
334− // can keep `serveIndex` non-nullable.
335− new Response("vite not ready", { status: 503 });
336− } else if (opts.embeddedWebUI) {
337− staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
338− const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"));
339− serveIndex = () => htmlResponse(indexFile);
340− } else {
341− staticRoutes = collectStaticRoutes(clientDir);
342− const indexFile = Bun.file(join(clientDir, "index.html"));
343− serveIndex = () => htmlResponse(indexFile);
344− }
345−
346− const server = Bun.serve({
347− port,
348− hostname,
349− // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume
350− // can idle longer during human approval; `0` disables the socket timeout.
351− idleTimeout: 0,
352− routes: { ...staticRoutes },
353− async fetch(req) {
354− const withCors = (response: Response): Response =>
355− withCorsHeaders(req, response, corsAllowedHosts);
356−
357− if (req.method === "OPTIONS" && req.headers.has("origin")) {
358− return corsPreflightResponse(req, corsAllowedHosts);
359− }
360−
361− const url = new URL(req.url);
362−
363− // Unauthenticated liveness probe — carries no data, used by the CLI
364− // reachability check (which therefore never forwards a credential).
365− if (url.pathname === "/api/health" && req.method === "GET") {
366− return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
367− }
368−
369− // OAuth callbacks and CIMD documents are reached by the external
370− // provider, which cannot carry our local bearer. Everything else under
371− // /api and /mcp requires the bearer.
372− const skipAuth = isUnauthenticatedOAuthPath(url.pathname);
373− const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
374− const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;
375−
376− if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
377− return withCors(
378− new Response("Unauthorized", {
379− status: 401,
380− headers: { "www-authenticate": 'Bearer realm="executor"' },
381− }),
382− );
383− }
384−
385− if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") {
386− return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
387− }
388−
389− if (isMcpPath) {
390− return withCors(await handlers.mcp.handleRequest(req));
391− }
392−
393− if (url.pathname.startsWith("/api/mcp-sessions/")) {
394− // GET → paused-execution detail for the approval page; POST → record the
395− // decision. Both are bearer-gated above.
396− const handler =
397− req.method === "GET"
398− ? handlers.mcp.handlePausedRequest
399− : handlers.mcp.handleApprovalRequest;
400− return withCors(await handler(req));
401− }
402−
403− // OAuth result polling — local-only, served outside the typed API
404− // because cloud (Cloudflare Workers, stateless) can't back the
405− // in-memory store. See setOAuthCompletionListener above.
406− const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
407− if (awaitMatch && req.method === "GET") {
408− const result = consumeOAuthResult(awaitMatch[1]);
409− return withCors(
410− new Response(JSON.stringify(result), {
411− headers: { "content-type": "application/json" },
412− }),
413− );
414− }
415−
416− if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
417− url.pathname = url.pathname.slice("/api".length) || "/";
418− return withCors(await handlers.api.handler(new Request(url, req)));
419− }
420−
421− // Dev mode: forward everything else (SPA + hashed assets) to the
422− // vite child so source edits show up without a rebuild.
423− if (viteChild) {
424− return withCors(await proxyToVite(req, viteChild.url));
425− }
426−
427− // If a path looks like a static asset (has a file extension), do not
428− // fall back to SPA HTML. Returning index.html here causes browser module
429− // MIME errors when hashed chunks are stale/missing.
430− if (hasFileExtension(url.pathname)) {
431− return withCors(new Response("Not Found", { status: 404 }));
432− }
320+ const disposeOwnedResources = async (): Promise<void> => {
321+ setOAuthCompletionListener(null);
322+ if (ownsHandlers) {
323+ await disposeServerHandlers();
324+ } else {
325+ await closeProvidedHandlers(handlers);
326+ }
327+ if (viteChild) await viteChild.stop();
328+ };
433329
434− // SPA fallback (unauthenticated — the browser loads the shell, then reads
435− // its token and sends the bearer on subsequent /api calls).
436− return withCors(await serveIndex());
437− },
438− error(error) {
439− console.error("Server error:", error);
440− return new Response("Internal Server Error", { status: 500 });
441− },
442− });
330+ // 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
331+ try {
332+ // Mirror every OAuth callback completion into the local in-memory result
333+ // store. The Electron desktop renderer polls /api/oauth/await/:sessionId
334+ // for these when the user runs the flow in their system browser (no
335+ // shared origin → no postMessage). Cloud doesn't register a listener;
336+ // its same-origin web SPA receives results via postMessage directly.
337+ setOAuthCompletionListener((result) => publishOAuthResult(result));
338+
339+ // Build static routes from either embedded assets, disk, or a spawned
340+ // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and
341+ // disables the file-extension 404 short-circuit since vite serves
342+ // hashed asset paths directly.
343+ let staticRoutes: Record<string, StaticHandler> = {};
344+ let serveIndex: StaticHandler;
345+
346+ const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
347+ if (devMode) {
348+ console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");
349+ viteChild = await startViteChild();
350+ // Diagnostic only — this is the internal vite port the daemon proxies to.
351+ // It must NOT read as a destination: the URL to open is the `Open:` line the
352+ // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
353+ // skips that bootstrap and lands on the auth gate.
354+ console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);
355+ serveIndex = () =>
356+ // Unused when viteChild is non-null; defined so the type checker
357+ // can keep `serveIndex` non-nullable.
358+ new Response("vite not ready", { status: 503 });
359+ } else if (opts.embeddedWebUI) {
360+ staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
361+ const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"));
362+ serveIndex = () => htmlResponse(indexFile);
363+ } else {
364+ staticRoutes = collectStaticRoutes(clientDir);
365+ const indexFile = Bun.file(join(clientDir, "index.html"));
366+ serveIndex = () => htmlResponse(indexFile);
367+ }
443368
444− return {
445− port: server.port!,
446− authToken,
447− async stop() {
448− setOAuthCompletionListener(null);
449− server.stop(true);
450− await handlers.mcp.close();
451− await handlers.api.dispose();
452− if (viteChild) await viteChild.stop();
453− },
454− };
369+ const server = Bun.serve({
370+ port,
371+ hostname,
372+ // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume
373+ // can idle longer during human approval; `0` disables the socket timeout.
374+ idleTimeout: 0,
375+ routes: { ...staticRoutes },
376+ async fetch(req) {
377+ const withCors = (response: Response): Response =>
378+ withCorsHeaders(req, response, corsAllowedHosts);
379+
380+ if (req.method === "OPTIONS" && req.headers.has("origin")) {
381+ return corsPreflightResponse(req, corsAllowedHosts);
382+ }
383+
384+ const url = new URL(req.url);
385+
386+ // Unauthenticated liveness probe — carries no data, used by the CLI
387+ // reachability check (which therefore never forwards a credential).
388+ if (url.pathname === "/api/health" && req.method === "GET") {
389+ return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
390+ }
391+
392+ // OAuth callbacks and CIMD documents are reached by the external
393+ // provider, which cannot carry our local bearer. Everything else under
394+ // /api and /mcp requires the bearer.
395+ const skipAuth = isUnauthenticatedOAuthPath(url.pathname);
396+ const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
397+ const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;
398+
399+ if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
400+ return withCors(
401+ new Response("Unauthorized", {
402+ status: 401,
403+ headers: { "www-authenticate": 'Bearer realm="executor"' },
404+ }),
405+ );
406+ }
407+
408+ if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") {
409+ return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
410+ }
411+
412+ if (isMcpPath) {
413+ return withCors(await handlers.mcp.handleRequest(req));
414+ }
415+
416+ if (url.pathname.startsWith("/api/mcp-sessions/")) {
417+ // GET → paused-execution detail for the approval page; POST → record the
418+ // decision. Both are bearer-gated above.
419+ const handler =
420+ req.method === "GET"
421+ ? handlers.mcp.handlePausedRequest
422+ : handlers.mcp.handleApprovalRequest;
423+ return withCors(await handler(req));
424+ }
425+
426+ // OAuth result polling — local-only, served outside the typed API
427+ // because cloud (Cloudflare Workers, stateless) can't back the
428+ // in-memory store. See setOAuthCompletionListener above.
429+ const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
430+ if (awaitMatch && req.method === "GET") {
431+ const result = consumeOAuthResult(awaitMatch[1]);
432+ return withCors(
433+ new Response(JSON.stringify(result), {
434+ headers: { "content-type": "application/json" },
435+ }),
436+ );
437+ }
438+
439+ if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
440+ url.pathname = url.pathname.slice("/api".length) || "/";
441+ return withCors(await handlers.api.handler(new Request(url, req)));
442+ }
443+
444+ // Dev mode: forward everything else (SPA + hashed assets) to the
445+ // vite child so source edits show up without a rebuild.
446+ if (viteChild) {
447+ return withCors(await proxyToVite(req, viteChild.url));
448+ }
449+
450+ // If a path looks like a static asset (has a file extension), do not
451+ // fall back to SPA HTML. Returning index.html here causes browser module
452+ // MIME errors when hashed chunks are stale/missing.
453+ if (hasFileExtension(url.pathname)) {
454+ return withCors(new Response("Not Found", { status: 404 }));
455+ }
456+
457+ // SPA fallback (unauthenticated — the browser loads the shell, then reads
458+ // its token and sends the bearer on subsequent /api calls).
459+ return withCors(await serveIndex());
460+ },
461+ error(error) {
462+ console.error("Server error:", error);
463+ return new Response("Internal Server Error", { status: 500 });
464+ },
465+ });
466+
467+ let stopped = false;
468+ return {
469+ port: server.port!,
470+ authToken,
471+ async stop() {
472+ if (stopped) return;
473+ stopped = true;
474+ server.stop(true);
475+ await disposeOwnedResources();
476+ },
477+ };
478+ } catch (cause) {
479+ await ignoreCleanupFailure(disposeOwnedResources);
480+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async server start must reject with the original startup failure after cleanup
481+ throw cause;
482+ }
455483 }
456484
457485 if (import.meta.main) {