Unified · Split
| @@ -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"; |
| 92 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 95 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 93 | import { fetchIntegrations } from "./integrations"; | 96 | import { 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. */ |
| 180 | const DOCS_URL = "https://executor.sh/docs"; | 183 | const DOCS_URL = "https://executor.sh/docs"; |
| 181 | const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; | 184 | const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; |
| 185 | const DESKTOP_SIDECAR_READY_SENTINEL = "EXECUTOR_READY"; | ||
| 186 | const DESKTOP_SIDECAR_ATTACHED_SENTINEL = "EXECUTOR_ATTACHED"; | ||
| 182 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; | 187 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; |
| 183 | const DAEMON_BOOT_POLL_MS = 150; | 188 | const DAEMON_BOOT_POLL_MS = 150; |
| 184 | const DAEMON_STOP_TIMEOUT_MS = 10_000; | 189 | const DAEMON_STOP_TIMEOUT_MS = 10_000; |
| @@ -188,6 +193,8 @@ const SERVICE_BOOT_TIMEOUT_MS = 45_000; · 3 unmodified lines | |||
| 188 | // Helpers | 193 | // Helpers |
| 189 | // --------------------------------------------------------------------------- | 194 | // --------------------------------------------------------------------------- |
| 190 | 195 | ||
| 196 | const shouldEmitDesktopSidecarSentinels = (): boolean => process.env.EXECUTOR_CLIENT === "desktop"; | ||
| 197 | |||
| 191 | const waitForShutdownSignal = () => | 198 | const 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 | |||
| 206 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => | 213 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => |
| 207 | isExecutorServerReachable({ baseUrl }); | 214 | isExecutorServerReachable({ baseUrl }); |
| 208 | 215 | ||
| 209 | const readActiveLocalServerManifest = (): Effect.Effect< | 216 | const readReachableLocalServerHint = (): Effect.Effect< |
| 210 | ExecutorLocalServerManifest | null, | 217 | ExecutorLocalServerManifest | null, |
| 211 | Error, | 218 | never, |
| 212 | FileSystem.FileSystem | PlatformPath.Path | 219 | 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 | ||
| 237 | const readActiveLocalServerManifest = readReachableLocalServerHint; | ||
| 238 | |||
| 238 | const normalizeDaemonScopeDir = (dir: string): string => { | 239 | const 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 | ||
| 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 | |||
| 268 | const parseDaemonUrl = (baseUrl: string) => | 311 | const 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 | ||
| 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 | |||
| 423 | // Serialize daemon startup behind a filesystem lock so concurrent CLI invocations don't | 498 | // 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 where | 499 | // 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({ |
| @@ -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_TOKEN | 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 | 8 | * 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 | ||
| 12 | import { spawn, type ChildProcess } from "node:child_process"; | 13 | import { 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 chatty | 36 | // 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. |
| 37 | const STDERR_TAIL_LIMIT = 8 * 1024; | 38 | const STDERR_TAIL_LIMIT = 8 * 1024; |
| 39 | const READY_SENTINEL = "EXECUTOR_READY"; | ||
| 40 | const 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 | ||
| 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 | |||
| 154 | const readLockPid = (dataDir: string): number | null => { | 140 | const 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 main | 272 | // 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 child | 273 | // 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 failures | 299 | 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 lock | 302 | 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 promise | 384 | // 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" or | 440 | 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 promise | 447 | } |
| 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 | ||
| 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 | |||
| 471 | /** | 523 | /** |
| 472 | * Attach to an already-running OS-supervised daemon instead of spawning our own | 524 | * Attach to an already-running OS-supervised daemon instead of spawning our own |
| 473 | * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a | 525 | * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a |
| @@ -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 | ||
| 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 | |||
| 67 | export const acquireDataDirOwnership = async (dataDir: string): Promise<DataDirOwnership> => { | 76 | export 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); |
| @@ -18,12 +18,10 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | import executorConfig from "../executor.config"; | 19 | import executorConfig from "../executor.config"; |
| 20 | import { localDataMigrations } from "./db/data-migrations"; | 20 | import { localDataMigrations } from "./db/data-migrations"; |
| 21 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; | 21 | import { openOwnedLocalDatabase } from "./db/owned-database"; |
| 22 | import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; | ||
| 23 | 22 | ||
| 24 | interface ResolvedStorage { | 23 | interface ResolvedStorage { |
| 25 | readonly dataDir: string; | 24 | readonly dataDir: string; |
| 26 | readonly sqlitePath: string; | ||
| 27 | } | 25 | } |
| 28 | 26 | ||
| 29 | const localNamespace = "executor_local"; | 27 | const localNamespace = "executor_local"; |
| @@ -36,10 +34,7 @@ const LOCAL_SUBJECT = "local"; · 6 unmodified lines | |||
| 36 | const resolveStorage = (): ResolvedStorage => { | 34 | const 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 with | 40 | // 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 is | 169 | // Boot-time data migrations: each registry entry runs once and is |
| 187 | // stamped in the `data_migration` ledger; stamped entries are skipped | 170 | // stamped in the `data_migration` ledger; stamped entries are skipped |
| @@ -14,4 +14,9 @@ export { · 13 unmodified lines | |||
| 14 | } from "./executor"; | 14 | } from "./executor"; |
| 15 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; | 15 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; |
| 16 | export { startServer, type StartServerOptions, type ServerInstance } from "./serve"; | 16 | export { startServer, type StartServerOptions, type ServerInstance } from "./serve"; |
| 17 | export { | ||
| 18 | DataDirOwnershipHeld, | ||
| 19 | findDataDirOwnershipHeld, | ||
| 20 | type DataDirOwnership, | ||
| 21 | } from "./db/data-dir-ownership"; | ||
| 17 | export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth"; | 22 | export { loadOrMintLocalAuthToken, rotateLocalAuthToken, localAuthTokenPath } from "./auth"; |
| @@ -1,9 +1,9 @@ | |||
| 1 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; | 1 | import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; |
| 2 | 2 | ||
| 3 | import { createExecutionEngine } from "@executor-js/execution"; | 3 | import { createExecutionEngine } from "@executor-js/execution"; |
| 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 5 | import { makeLocalApiHandler } from "./app"; | 5 | import { makeLocalApiHandler } from "./app"; |
| 6 | import { createExecutorHandle, getExecutorBundle } from "./executor"; | 6 | import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; |
| 7 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 7 | import { 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 | ||
| 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 | |||
| 38 | const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { | 52 | const 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 | ||
| 56 | export const createServerHandlers = async (token: string): Promise<ServerHandlers> => { | 70 | export const createServerHandlers = async (token: string): Promise<ServerHandlers> => { |
| 57 | // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The | 71 | 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 own | 75 | try { |
| 62 | // engine instance (the browser-approval + stdio surface is local-only and not | 76 | // 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 is | 77 | // 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 | ||
| 91 | export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()( | 130 | export 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 | }; |
| @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines | |||
| 3 | import { tmpdir } from "node:os"; | 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 5 | 5 | ||
| 6 | import { acquireDataDirOwnership } from "./db/data-dir-ownership"; | ||
| 6 | import { startServer, type ServerInstance } from "./serve"; | 7 | import { startServer, type ServerInstance } from "./serve"; |
| 7 | 8 | ||
| 8 | let clientDir: string; | 9 | let clientDir: string; |
| @@ -40,8 +41,9 @@ const startTestServer = async ( · 31 unmodified lines | |||
| 40 | beforeEach(() => { | 41 | beforeEach(() => { |
| 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 | ||
| 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 | |||
| 84 | describe("startServer bearer auth", () => { | 133 | describe("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() }); |
| @@ -15,7 +15,7 @@ import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/serve · 14 unmodified lines | |||
| 15 | import { loadOrMintLocalAuthToken } from "./auth"; | 15 | import { loadOrMintLocalAuthToken } from "./auth"; |
| 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; | 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; |
| 17 | import { startIntegrationsRefresh } from "./integrations"; | 17 | import { startIntegrationsRefresh } from "./integrations"; |
| 18 | import { getServerHandlers } from "./main"; | 18 | import { disposeServerHandlers, getServerHandlers } from "./main"; |
| 19 | import { | 19 | import { |
| 20 | DEFAULT_ALLOWED_HOSTS, | 20 | DEFAULT_ALLOWED_HOSTS, |
| 21 | hasFileExtension, | 21 | hasFileExtension, |
| @@ -285,6 +285,16 @@ const withCorsHeaders = ( · 263 unmodified lines | |||
| 285 | const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response => | 285 | const 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 | ||
| 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 | |||
| 288 | export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { | 298 | export 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 the | 325 | await closeProvidedHandlers(handlers); |
| 329 | // CLI prints (the daemon port, with ?_token). Hitting the vite port directly | 326 | } |
| 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 reads | 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 |
| 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 | ||
| 457 | if (import.meta.main) { | 485 | if (import.meta.main) { |