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
88 getExecutor,88 getExecutor,
89 rotateLocalAuthToken,89 rotateLocalAuthToken,
90 localAuthTokenPath,90 localAuthTokenPath,
91 findDataDirOwnershipHeld,
92 type ServerInstance,
93 type StartServerOptions,
91} from "@executor-js/local";94} from "@executor-js/local";
92import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";95import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
93import { fetchIntegrations } from "./integrations";96import { fetchIntegrations } from "./integrations";
@@ -179,6 +182,8 @@ const DEFAULT_PORT = 4788; · 85 unmodified lines
179/** Canonical public docs (Mintlify), matching the web shell's DEFAULT_DOCS_URL. */182/** Canonical public docs (Mintlify), matching the web shell's DEFAULT_DOCS_URL. */
180const DOCS_URL = "https://executor.sh/docs";183const DOCS_URL = "https://executor.sh/docs";
181const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;184const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
185const DESKTOP_SIDECAR_READY_SENTINEL = "EXECUTOR_READY";
186const DESKTOP_SIDECAR_ATTACHED_SENTINEL = "EXECUTOR_ATTACHED";
182const DAEMON_BOOT_TIMEOUT_MS = 15_000;187const DAEMON_BOOT_TIMEOUT_MS = 15_000;
183const DAEMON_BOOT_POLL_MS = 150;188const DAEMON_BOOT_POLL_MS = 150;
184const DAEMON_STOP_TIMEOUT_MS = 10_000;189const DAEMON_STOP_TIMEOUT_MS = 10_000;
@@ -188,6 +193,8 @@ const SERVICE_BOOT_TIMEOUT_MS = 45_000; · 3 unmodified lines
188// Helpers193// Helpers
189// ---------------------------------------------------------------------------194// ---------------------------------------------------------------------------
190 195
196const shouldEmitDesktopSidecarSentinels = (): boolean => process.env.EXECUTOR_CLIENT === "desktop";
197
191const waitForShutdownSignal = () =>198const waitForShutdownSignal = () =>
192 Effect.callback<void, never>((resume) => {199 Effect.callback<void, never>((resume) => {
193 const shutdown = () => resume(Effect.void);200 const shutdown = () => resume(Effect.void);
@@ -206,9 +213,9 @@ const waitForShutdownSignal = () => · 12 unmodified lines
206const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>213const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>
207 isExecutorServerReachable({ baseUrl });214 isExecutorServerReachable({ baseUrl });
208 215
209const readActiveLocalServerManifest = (): Effect.Effect<216const readReachableLocalServerHint = (): Effect.Effect<
210 ExecutorLocalServerManifest | null,217 ExecutorLocalServerManifest | null,
211 Error,218 never,
212 FileSystem.FileSystem | PlatformPath.Path219 FileSystem.FileSystem | PlatformPath.Path
213> =>220> =>
214 Effect.gen(function* () {221 Effect.gen(function* () {
@@ -224,17 +231,11 @@ const readActiveLocalServerManifest = (): Effect.Effect< · 9 unmodified lines
224 return null;231 return null;
225 }232 }
226 233
227 return yield* Effect.fail(234 return null;
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 );
236 });235 });
237 236
237const readActiveLocalServerManifest = readReachableLocalServerHint;
238
238const normalizeDaemonScopeDir = (dir: string): string => {239const normalizeDaemonScopeDir = (dir: string): string => {
239 const resolved = resolve(dir);240 const resolved = resolve(dir);
240 return existsSync(resolved) ? realpathSync.native(resolved) : resolved;241 return existsSync(resolved) ? realpathSync.native(resolved) : resolved;
@@ -265,6 +266,48 @@ interface ExecuteCodeResult { · 24 unmodified lines
265 readonly outcome: ExecuteCodeOutcome;266 readonly outcome: ExecuteCodeOutcome;
266}267}
267 268
269type LocalServerStartResult =
270 | { readonly kind: "started"; readonly server: ServerInstance }
271 | { readonly kind: "attached"; readonly manifest: ExecutorLocalServerManifest };
272
273const 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
294const 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
268const parseDaemonUrl = (baseUrl: string) =>311const parseDaemonUrl = (baseUrl: string) =>
269 Effect.try({312 Effect.try({
270 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),313 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
@@ -420,6 +463,38 @@ const resolveDaemonTarget = (baseUrl: string) => · 149 unmodified lines
420 };463 };
421 });464 });
422 465
466const 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
423// Serialize daemon startup behind a filesystem lock so concurrent CLI invocations don't498// Serialize daemon startup behind a filesystem lock so concurrent CLI invocations don't
424// each spawn their own daemon. The post-lock pointer recheck catches the case where499// each spawn their own daemon. The post-lock pointer recheck catches the case where
425// another invocation finished bootstrapping while we were waiting for the lock.500// another invocation finished bootstrapping while we were waiting for the lock.
@@ -476,25 +551,21 @@ const spawnAndWaitForDaemon = (input: { · 50 unmodified lines
476 env: process.env,551 env: process.env,
477 });552 });
478 553
479 const ready = yield* waitForReachable({554 const readyBaseUrl = yield* waitForDaemonStartupTarget({ requestedBaseUrl: startBaseUrl });
480 check: isServerReachable(startBaseUrl),
481 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
482 intervalMs: DAEMON_BOOT_POLL_MS,
483 });
484 555
485 if (!ready) {556 if (!readyBaseUrl) {
486 yield* terminateSpawnedDetachedProcess(child).pipe(Effect.ignore);557 yield* terminateSpawnedDetachedProcess(child).pipe(Effect.ignore);
487 return yield* Effect.fail(558 return yield* Effect.fail(
488 new Error(559 new Error(
489 [560 [
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.`,
491 `Run in foreground to inspect logs: ${cliPrefix} daemon run --foreground --port ${selectedPort} --hostname ${input.host}`,562 `Run in foreground to inspect logs: ${cliPrefix} daemon run --foreground --port ${selectedPort} --hostname ${input.host}`,
492 ].join("\n"),563 ].join("\n"),
493 ),564 ),
494 );565 );
495 }566 }
496 567
497 return startBaseUrl;568 return readyBaseUrl;
498 } finally {569 } finally {
499 yield* releaseDaemonStartLock(lock).pipe(Effect.ignore);570 yield* releaseDaemonStartLock(lock).pipe(Effect.ignore);
500 }571 }
@@ -950,15 +1021,18 @@ const runForegroundSession = (input: { · 449 unmodified lines
950 1021
951 try {1022 try {
952 yield* assertNoOtherActiveLocalServer();1023 yield* assertNoOtherActiveLocalServer();
953 server = yield* Effect.promise(() =>1024 const startResult = yield* startServerOrAttachOwnedDataDir({
954 startServer({1025 port: input.port,
955 port: input.port,1026 hostname: input.hostname,
956 hostname: input.hostname,1027 allowedHosts: input.allowedHosts,
957 allowedHosts: input.allowedHosts,1028 authToken: input.authToken,
958 authToken: input.authToken,1029 embeddedWebUI,
959 embeddedWebUI,1030 });
960 }),1031 if (startResult.kind === "attached") {
961 );1032 console.log(`Executor is already running at ${startResult.manifest.connection.origin}.`);
1033 return;
1034 }
1035 server = startResult.server;
962 baseUrl = `http://${displayHost}:${server.port}`;1036 baseUrl = `http://${displayHost}:${server.port}`;
963 yield* publishLocalServerManifest({1037 yield* publishLocalServerManifest({
964 kind: "foreground",1038 kind: "foreground",
@@ -1074,15 +1148,21 @@ const runDaemonSession = (input: { · 109 unmodified lines
1074 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });1148 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
1075 }1149 }
1076 1150
1077 server = yield* Effect.promise(() =>1151 const startResult = yield* startServerOrAttachOwnedDataDir({
1078 startServer({1152 port: input.port,
1079 port: input.port,1153 hostname: input.hostname,
1080 hostname: input.hostname,1154 allowedHosts: input.allowedHosts,
1081 allowedHosts: input.allowedHosts,1155 authToken: input.authToken,
1082 authToken: input.authToken,1156 embeddedWebUI,
1083 embeddedWebUI,1157 });
1084 }),1158 if (startResult.kind === "attached") {
1085 );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;
1086 1166
1087 daemonPort = server.port;1167 daemonPort = server.port;
1088 token = randomUUID();1168 token = randomUUID();
@@ -1120,6 +1200,9 @@ const runDaemonSession = (input: { · 31 unmodified lines
1120 token,1200 token,
1121 });1201 });
1122 1202
1203 if (shouldEmitDesktopSidecarSentinels()) {
1204 console.log(`${DESKTOP_SIDECAR_READY_SENTINEL}:${daemonPort}`);
1205 }
1123 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);1206 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);
1124 1207
1125 yield* waitForShutdownSignal();1208 yield* waitForShutdownSignal();
@@ -1216,28 +1299,61 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 90 unmodified lines
1216 1299
1217 try {1300 try {
1218 yield* assertNoOtherActiveLocalServer();1301 yield* assertNoOtherActiveLocalServer();
1219 web = yield* Effect.promise(() =>1302 web = yield* Effect.tryPromise({
1220 withStdoutReroutedToStderr(async () => {1303 try: () =>
1221 const host = "127.0.0.1";1304 withStdoutReroutedToStderr(async () => {
1222 const port = await Effect.runPromise(1305 const host = "127.0.0.1";
1223 chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),1306 const port = await Effect.runPromise(
1224 );1307 chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
1225 const baseUrl = `http://localhost:${port}`;1308 );
1226 const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);1309 const baseUrl = `http://localhost:${port}`;
1227 1310 const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
1228 try {1311
1229 const executor = await getExecutor();1312 try {
1230 const server = await startServer({1313 const executor = await getExecutor();
1231 port,1314 const server = await startServer({
1232 hostname: host,1315 port,
1233 embeddedWebUI,1316 hostname: host,
1234 });1317 embeddedWebUI,
1235 const serverBaseUrl = `http://localhost:${server.port}`;1318 });
1236 return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };1319 const serverBaseUrl = `http://localhost:${server.port}`;
1237 } catch (cause) {1320 return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
1238 restoreWebBaseUrl();1321 } catch (cause) {
1239 throw cause;1322 restoreWebBaseUrl();
1240 }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 });
1241 }),1357 }),
1242 );1358 );
1243 yield* publishLocalServerManifest({1359 yield* publishLocalServerManifest({
apps/desktop/src/main/sidecar.ts+92−40
@@ -4,9 +4,10 @@ · 3 unmodified lines
4 * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`.4 * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`.
5 * In prod: spawns the bundled CLI binary in foreground daemon mode.5 * In prod: spawns the bundled CLI binary in foreground daemon mode.
6 *6 *
7 * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN7 * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN.
8 * The dev sidecar announces `EXECUTOR_READY:<port>`. The packaged CLI daemon8 * The dev sidecar and packaged CLI child announce structured stdout sentinels
9 * announces `Daemon ready on http://host:port`. This controller accepts both.9 * (`EXECUTOR_READY:<port>` or `EXECUTOR_ATTACHED`). Human-readable log text is
10 * never part of the desktop startup contract.
10 */11 */
11 12
12import { spawn, type ChildProcess } from "node:child_process";13import { spawn, type ChildProcess } from "node:child_process";
@@ -35,6 +36,8 @@ const sidecarLog = log.scope("sidecar"); · 22 unmodified lines
35// Rolling stderr tail attached to crash reports. Bounded so a chatty36// Rolling stderr tail attached to crash reports. Bounded so a chatty
36// sidecar can't grow it unbounded over a long session.37// sidecar can't grow it unbounded over a long session.
37const STDERR_TAIL_LIMIT = 8 * 1024;38const STDERR_TAIL_LIMIT = 8 * 1024;
39const READY_SENTINEL = "EXECUTOR_READY";
40const ATTACHED_SENTINEL = "EXECUTOR_ATTACHED";
38 41
39// Children deliberately stopped via stopSidecar (quit, restart, update) —42// Children deliberately stopped via stopSidecar (quit, restart, update) —
40// their exits are expected and must not be reported as crashes.43// their exits are expected and must not be reported as crashes.
@@ -134,23 +137,6 @@ const removeManifestIfOwnedBy = (dataDir: string, pid: number) => { · 93 unmodified lines
134 rmSync(localServerManifestPath(dataDir), { force: true });137 rmSync(localServerManifestPath(dataDir), { force: true });
135};138};
136 139
137const 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
154const readLockPid = (dataDir: string): number | null => {140const readLockPid = (dataDir: string): number | null => {
155 const path = localServerStartLockPath(dataDir);141 const path = localServerStartLockPath(dataDir);
156 if (!existsSync(path)) return null;142 if (!existsSync(path)) return null;
@@ -277,6 +263,12 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 120 unmodified lines
277 const dataDir = scopeDir;263 const dataDir = scopeDir;
278 mkdirSync(dataDir, { recursive: true });264 mkdirSync(dataDir, { recursive: true });
279 265
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
280 // The stable bearer token from auth.json (shared with the CLI). The main272 // The stable bearer token from auth.json (shared with the CLI). The main
281 // process holds it so it can inject the header into the webview; the child273 // process holds it so it can inject the header into the webview; the child
282 // validates against the same value. Always present — auth is unconditional.274 // 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
304 };296 };
305 297
306 if (!cliManagedManifest) {298 if (!cliManagedManifest) {
307 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures299 const attachedAfterLock = await attachToReachableLocalServer(dataDir);
308 try {300 if (attachedAfterLock) {
309 assertNoOtherLocalServerOwner(dataDir);
310 } catch (error) {
311 releaseLock();301 releaseLock();
312 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock302 return attachedAfterLock;
313 throw error;
314 }303 }
315 }304 }
316 305
@@ -348,6 +337,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 31 unmodified lines
348 337
349 return new Promise<SidecarConnection>((resolveStart, rejectStart) => {338 return new Promise<SidecarConnection>((resolveStart, rejectStart) => {
350 let stderrBuffer = "";339 let stderrBuffer = "";
340 let stdoutControlBuffer = "";
351 let resolved = false;341 let resolved = false;
352 let rejected = false;342 let rejected = false;
353 343
@@ -362,14 +352,33 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 8 unmodified lines
362 rejectStart(err);352 rejectStart(err);
363 };353 };
364 354
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
365 const onStdout = (chunk: Buffer) => {366 const onStdout = (chunk: Buffer) => {
366 const text = chunk.toString("utf8");367 const text = chunk.toString("utf8");
367 process.stdout.write(`[executor-server] ${text}`);368 process.stdout.write(`[executor-server] ${text}`);
368 logStdoutLine(text);369 logStdoutLine(text);
369 const match =370 stdoutControlBuffer += text;
370 text.match(/EXECUTOR_READY:(\d+)/) ??371 const rawControlLines = stdoutControlBuffer.split(/\r?\n/);
371 text.match(/Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/);372 stdoutControlBuffer = rawControlLines.pop() ?? "";
372 if (match && !resolved) {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) {
373 if (!child.pid) {382 if (!child.pid) {
374 reject(383 reject(
375 // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar startup failure surfaces here as a rejected start promise384 // 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
378 return;387 return;
379 }388 }
380 resolved = true;389 resolved = true;
381 const port = parseInt(match[1], 10);390 const port = parseInt(readyLine.slice(`${READY_SENTINEL}:`.length), 10);
382 const baseUrl = `http://${hostname}:${port}`;391 const baseUrl = `http://${hostname}:${port}`;
383 if (!cliManagedManifest) {392 if (!cliManagedManifest) {
384 writeSidecarManifest({393 writeSidecarManifest({
@@ -428,15 +437,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 43 unmodified lines
428 return;437 return;
429 }438 }
430 if (rejected) return;439 if (rejected) return;
431 // Detect bind failure — the Node listener prints either "EADDRINUSE" or440 void (async () => {
432 // "address already in use" on stderr before exiting non-zero.441 if (await resolveAttachedOwner()) return;
433 if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) {442 // Detect bind failure — the Node listener prints either "EADDRINUSE" or
434 reject(new SidecarPortInUseError(settings.port));443 // "address already in use" on stderr before exiting non-zero.
435 return;444 if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) {
436 }445 reject(new SidecarPortInUseError(settings.port));
437 const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`;446 return;
438 // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise447 }
439 reject(new Error(message));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 })();
440 };452 };
441 453
442 child.stdout?.on("data", onStdout);454 child.stdout?.on("data", onStdout);
@@ -468,6 +480,46 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 25 unmodified lines
468 return false;480 return false;
469};481};
470 482
483const 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
471/**523/**
472 * Attach to an already-running OS-supervised daemon instead of spawning our own524 * Attach to an already-running OS-supervised daemon instead of spawning our own
473 * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a525 * 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
64 await client.execute("PRAGMA journal_mode = DELETE");64 await client.execute("PRAGMA journal_mode = DELETE");
65};65};
66 66
67export 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
67export const acquireDataDirOwnership = async (dataDir: string): Promise<DataDirOwnership> => {76export const acquireDataDirOwnership = async (dataDir: string): Promise<DataDirOwnership> => {
68 const lockPath = ownerLockPath(dataDir);77 const lockPath = ownerLockPath(dataDir);
69 const client = openOwnershipLockClient(lockPath);78 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
18 18
19import executorConfig from "../executor.config";19import executorConfig from "../executor.config";
20import { localDataMigrations } from "./db/data-migrations";20import { localDataMigrations } from "./db/data-migrations";
21import { createSqliteFumaDb } from "./db/sqlite-fumadb";21import { openOwnedLocalDatabase } from "./db/owned-database";
22import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";
23 22
24interface ResolvedStorage {23interface ResolvedStorage {
25 readonly dataDir: string;24 readonly dataDir: string;
26 readonly sqlitePath: string;
27}25}
28 26
29const localNamespace = "executor_local";27const localNamespace = "executor_local";
@@ -36,10 +34,7 @@ const LOCAL_SUBJECT = "local"; · 6 unmodified lines
36const resolveStorage = (): ResolvedStorage => {34const resolveStorage = (): ResolvedStorage => {
37 const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");35 const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");
38 fs.mkdirSync(dataDir, { recursive: true });36 fs.mkdirSync(dataDir, { recursive: true });
39 return {37 return { dataDir };
40 dataDir,
41 sqlitePath: join(dataDir, "data.db"),
42 };
43};38};
44 39
45// Hash suffix disambiguates same-basename folders so two projects with40// Hash suffix disambiguates same-basename folders so two projects with
@@ -151,28 +146,14 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 105 unmodified lines
151 const tenantId = makeTenantId(cwd);146 const tenantId = makeTenantId(cwd);
152 const tables = collectTables();147 const tables = collectTables();
153 148
154 const migration = yield* Effect.tryPromise({149 const owned = yield* Effect.acquireRelease(
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(
170 Effect.tryPromise({150 Effect.tryPromise({
171 try: () =>151 try: () =>
172 createSqliteFumaDb({152 openOwnedLocalDatabase({
153 dataDir: storage.dataDir,
173 tables,154 tables,
174 namespace: localNamespace,155 namespace: localNamespace,
175 path: storage.sqlitePath,156 tenantId,
176 }),157 }),
177 catch: (cause) =>158 catch: (cause) =>
178 new LocalExecutorCreateError({159 new LocalExecutorCreateError({
@@ -180,8 +161,10 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 1 unmodified line
180 cause,161 cause,
181 }),162 }),
182 }),163 }),
183 (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),164 (database) => Effect.promise(() => database.close()).pipe(Effect.ignore),
184 );165 );
166 const sqlite = owned.db;
167 const migration = owned.migration;
185 168
186 // Boot-time data migrations: each registry entry runs once and is169 // Boot-time data migrations: each registry entry runs once and is
187 // stamped in the `data_migration` ledger; stamped entries are skipped170 // 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
14} from "./executor";14} from "./executor";
15export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp";15export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp";
16export { startServer, type StartServerOptions, type ServerInstance } from "./serve";16export { startServer, type StartServerOptions, type ServerInstance } from "./serve";
17export {
18 DataDirOwnershipHeld,
19 findDataDirOwnershipHeld,
20 type DataDirOwnership,
21} from "./db/data-dir-ownership";
17export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth";22export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth";
apps/local/src/main.ts+92−51
@@ -1,9 +1,9 @@
1import { Context, Effect, Layer, ManagedRuntime } from "effect";1import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";
2 2
3import { createExecutionEngine } from "@executor-js/execution";3import { createExecutionEngine } from "@executor-js/execution";
4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
5import { makeLocalApiHandler } from "./app";5import { makeLocalApiHandler } from "./app";
6import { createExecutorHandle, getExecutorBundle } from "./executor";6import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";
7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
8 8
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -35,57 +35,96 @@ export type ServerHandlers = { · 25 unmodified lines
35 readonly mcp: McpRequestHandler;35 readonly mcp: McpRequestHandler;
36};36};
37 37
38class ServerHandlersDisposeError extends Data.TaggedError("ServerHandlersDisposeError")<{
39 readonly operation: "api.dispose" | "mcp.close" | "disposeExecutor" | "runtime.dispose";
40 readonly cause: unknown;
41}> {}
42
43const 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
38const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => {52const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => {
39 await Effect.runPromise(53 await Effect.runPromise(
40 Effect.all(54 Effect.gen(function* () {
41 [55 yield* Effect.all(
42 Effect.tryPromise({56 [
43 try: () => handlers.api.dispose(),57 ignoreDisposeFailure("api.dispose", () => handlers.api.dispose()),
44 catch: (cause) => cause,58 ignoreDisposeFailure("mcp.close", () => handlers.mcp.close()),
45 }).pipe(Effect.ignore),59 ],
46 Effect.tryPromise({60 { concurrency: "unbounded" },
47 try: () => handlers.mcp.close(),61 );
48 catch: (cause) => cause,62 // The API/MCP handlers borrow the shared local executor handle. Release it
49 }).pipe(Effect.ignore),63 // after the surfaces are closed so server shutdown (and failed startup
50 ],64 // cleanup via disposeServerHandlers) releases the owned data-dir lock.
51 { concurrency: "unbounded" },65 yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
52 ),66 }),
53 );67 );
54};68};
55 69
56export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {70export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {
57 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The71 let apiHandler: ServerHandlers["api"] | null = null;
58 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).72 let mcp: McpRequestHandler | null = null;
59 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);73
60 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
61 // The in-process MCP server runs over the SAME boot executor, with its own75 try {
62 // engine instance (the browser-approval + stdio surface is local-only and not76 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
63 // part of the shared API). Reuse the shared boot bundle so the MCP executor is77 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
64 // byte-identical to the one the API serves.78 apiHandler = await makeLocalApiHandler(token);
65 const { executor } = await getExecutorBundle();79
66 const engine = createExecutionEngine({80 // The in-process MCP server runs over the SAME boot executor, with its own
67 executor,81 // engine instance (the browser-approval + stdio surface is local-only and not
68 codeExecutor: makeQuickJsExecutor(),82 // part of the shared API). Reuse the shared boot bundle so the MCP executor is
69 });83 // byte-identical to the one the API serves.
70 const mcp = createMcpRequestHandler({84 const { executor } = await getExecutorBundle();
71 defaultConfig: { engine },85 const engine = createExecutionEngine({
72 createConfigForResource: async (resource) => {86 executor,
73 if (resource.kind === "default") return { config: { engine } };87 codeExecutor: makeQuickJsExecutor(),
74 const handle = await createExecutorHandle({88 });
75 activeToolkitSlug: resource.slug,89 mcp = createMcpRequestHandler({
76 });90 defaultConfig: { engine },
77 const toolkitEngine = createExecutionEngine({91 createConfigForResource: async (resource) => {
78 executor: handle.executor,92 if (resource.kind === "default") return { config: { engine } };
79 codeExecutor: makeQuickJsExecutor(),93 const handle = await createExecutorHandle({
80 });94 activeToolkitSlug: resource.slug,
81 return {95 });
82 config: { engine: toolkitEngine },96 const toolkitEngine = createExecutionEngine({
83 close: handle.dispose,97 executor: handle.executor,
84 };98 codeExecutor: makeQuickJsExecutor(),
85 },99 });
86 });100 return {
87 101 config: { engine: toolkitEngine },
88 return { api: apiHandler, mcp };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 }
89};128};
90 129
91export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(130export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(
@@ -120,9 +159,11 @@ export const disposeServerHandlers = async (): Promise<void> => { · 28 unmodified lines
120 if (!runtime) return;159 if (!runtime) return;
121 serverHandlersRuntime = null;160 serverHandlersRuntime = null;
122 await Effect.runPromise(161 await Effect.runPromise(
123 Effect.tryPromise({162 Effect.gen(function* () {
124 try: () => runtime.dispose(),163 yield* ignoreDisposeFailure("runtime.dispose", () => runtime.dispose());
125 catch: (cause) => cause,164 // Belt-and-suspenders: runtime disposal normally reaches closeServerHandlers,
126 }).pipe(Effect.ignore),165 // but a failed runtime finalizer must not leave the shared DB owner alive.
166 yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
167 }),
127 );168 );
128};169};
apps/local/src/serve.test.ts+50−1
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines
3import { tmpdir } from "node:os";3import { tmpdir } from "node:os";
4import { join } from "node:path";4import { join } from "node:path";
5 5
6import { acquireDataDirOwnership } from "./db/data-dir-ownership";
6import { startServer, type ServerInstance } from "./serve";7import { startServer, type ServerInstance } from "./serve";
7 8
8let clientDir: string;9let clientDir: string;
@@ -40,8 +41,9 @@ const startTestServer = async ( · 31 unmodified lines
40beforeEach(() => {41beforeEach(() => {
41 clientDir = mkdtempSync(join(tmpdir(), "exec-local-serve-"));42 clientDir = mkdtempSync(join(tmpdir(), "exec-local-serve-"));
42 dataDir = mkdtempSync(join(tmpdir(), "exec-local-data-"));43 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.
44 process.env.EXECUTOR_DATA_DIR = dataDir;45 process.env.EXECUTOR_DATA_DIR = dataDir;
46 process.env.EXECUTOR_SCOPE_DIR = dataDir;
45 mkdirSync(join(clientDir, "assets"), { recursive: true });47 mkdirSync(join(clientDir, "assets"), { recursive: true });
46 writeFileSync(48 writeFileSync(
47 join(clientDir, "index.html"),49 join(clientDir, "index.html"),
@@ -56,6 +58,7 @@ afterEach(async () => { · 8 unmodified lines
56 server = null;58 server = null;
57 }59 }
58 delete process.env.EXECUTOR_DATA_DIR;60 delete process.env.EXECUTOR_DATA_DIR;
61 delete process.env.EXECUTOR_SCOPE_DIR;
59 rmSync(clientDir, { recursive: true, force: true });62 rmSync(clientDir, { recursive: true, force: true });
60 rmSync(dataDir, { recursive: true, force: true });63 rmSync(dataDir, { recursive: true, force: true });
61});64});
@@ -81,6 +84,52 @@ describe("startServer static/SPA routing (unauthenticated)", () => { · 19 unmodified lines
81 });84 });
82});85});
83 86
87describe("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
84describe("startServer bearer auth", () => {133describe("startServer bearer auth", () => {
85 it("mints and persists a 0600 auth.json when no token is supplied", async () => {134 it("mints and persists a 0600 auth.json when no token is supplied", async () => {
86 server = await startServer({ port: 0, clientDir, handlers: testHandlers() });135 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
15import { loadOrMintLocalAuthToken } from "./auth";15import { loadOrMintLocalAuthToken } from "./auth";
16import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";16import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
17import { startIntegrationsRefresh } from "./integrations";17import { startIntegrationsRefresh } from "./integrations";
18import { getServerHandlers } from "./main";18import { disposeServerHandlers, getServerHandlers } from "./main";
19import {19import {
20 DEFAULT_ALLOWED_HOSTS,20 DEFAULT_ALLOWED_HOSTS,
21 hasFileExtension,21 hasFileExtension,
@@ -285,6 +285,16 @@ const withCorsHeaders = ( · 263 unmodified lines
285const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>285const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>
286 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);286 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);
287 287
288const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => {
289 await handlers.mcp.close();
290 await handlers.api.dispose();
291};
292
293const 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
288export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {298export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
289 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);299 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
290 const hostname = opts.hostname ?? "127.0.0.1";300 const hostname = opts.hostname ?? "127.0.0.1";
@@ -303,155 +313,173 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 12 unmodified lines
303 313
304 startIntegrationsRefresh();314 startIntegrationsRefresh();
305 315
316 const ownsHandlers = opts.handlers === undefined;
306 const handlers = opts.handlers ?? (await getServerHandlers(authToken));317 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;
321 let viteChild: ViteChild | null = null;318 let viteChild: ViteChild | null = null;
322 319
323 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;320 const disposeOwnedResources = async (): Promise<void> => {
324 if (devMode) {321 setOAuthCompletionListener(null);
325 console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");322 if (ownsHandlers) {
326 viteChild = await startViteChild();323 await disposeServerHandlers();
327 // Diagnostic only — this is the internal vite port the daemon proxies to.324 } else {
328 // It must NOT read as a destination: the URL to open is the `Open:` line the325 await closeProvidedHandlers(handlers);
329 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly326 }
330 // skips that bootstrap and lands on the auth gate.327 if (viteChild) await viteChild.stop();
331 console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);328 };
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 }
433 329
434 // SPA fallback (unauthenticated — the browser loads the shell, then reads330 // 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
435 // its token and sends the bearer on subsequent /api calls).331 try {
436 return withCors(await serveIndex());332 // Mirror every OAuth callback completion into the local in-memory result
437 },333 // store. The Electron desktop renderer polls /api/oauth/await/:sessionId
438 error(error) {334 // for these when the user runs the flow in their system browser (no
439 console.error("Server error:", error);335 // shared origin → no postMessage). Cloud doesn't register a listener;
440 return new Response("Internal Server Error", { status: 500 });336 // its same-origin web SPA receives results via postMessage directly.
441 },337 setOAuthCompletionListener((result) => publishOAuthResult(result));
442 });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 }
443 368
444 return {369 const server = Bun.serve({
445 port: server.port!,370 port,
446 authToken,371 hostname,
447 async stop() {372 // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume
448 setOAuthCompletionListener(null);373 // can idle longer during human approval; `0` disables the socket timeout.
449 server.stop(true);374 idleTimeout: 0,
450 await handlers.mcp.close();375 routes: { ...staticRoutes },
451 await handlers.api.dispose();376 async fetch(req) {
452 if (viteChild) await viteChild.stop();377 const withCors = (response: Response): Response =>
453 },378 withCorsHeaders(req, response, corsAllowedHosts);
454 };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 }
455}483}
456 484
457if (import.meta.main) {485if (import.meta.main) {