Unified · Split
| @@ -88,6 +88,9 @@ import { · 87 unmodified lines | ||
| 88 | 88 | getExecutor, |
| 89 | 89 | rotateLocalAuthToken, |
| 90 | 90 | localAuthTokenPath, |
| 91 | + findDataDirOwnershipHeld, | |
| 92 | + type ServerInstance, | |
| 93 | + type StartServerOptions, | |
| 91 | 94 | } from "@executor-js/local"; |
| 92 | 95 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 93 | 96 | import { fetchIntegrations } from "./integrations"; |
| @@ -179,6 +182,8 @@ const DEFAULT_PORT = 4788; · 85 unmodified lines | ||
| 179 | 182 | /** Canonical public docs (Mintlify), matching the web shell's DEFAULT_DOCS_URL. */ |
| 180 | 183 | const DOCS_URL = "https://executor.sh/docs"; |
| 181 | 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 | 187 | const DAEMON_BOOT_TIMEOUT_MS = 15_000; |
| 183 | 188 | const DAEMON_BOOT_POLL_MS = 150; |
| 184 | 189 | const DAEMON_STOP_TIMEOUT_MS = 10_000; |
| @@ -188,6 +193,8 @@ const SERVICE_BOOT_TIMEOUT_MS = 45_000; · 3 unmodified lines | ||
| 188 | 193 | // Helpers |
| 189 | 194 | // --------------------------------------------------------------------------- |
| 190 | 195 | |
| 196 | +const shouldEmitDesktopSidecarSentinels = (): boolean => process.env.EXECUTOR_CLIENT === "desktop"; | |
| 197 | + | |
| 191 | 198 | const waitForShutdownSignal = () => |
| 192 | 199 | Effect.callback<void, never>((resume) => { |
| 193 | 200 | const shutdown = () => resume(Effect.void); |
| @@ -206,9 +213,9 @@ const waitForShutdownSignal = () => · 12 unmodified lines | ||
| 206 | 213 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => |
| 207 | 214 | isExecutorServerReachable({ baseUrl }); |
| 208 | 215 | |
| 209 | −const readActiveLocalServerManifest = (): Effect.Effect< | |
| 216 | +const readReachableLocalServerHint = (): Effect.Effect< | |
| 210 | 217 | ExecutorLocalServerManifest | null, |
| 211 | − Error, | |
| 218 | + never, | |
| 212 | 219 | FileSystem.FileSystem | PlatformPath.Path |
| 213 | 220 | > => |
| 214 | 221 | Effect.gen(function* () { |
| @@ -224,17 +231,11 @@ const readActiveLocalServerManifest = (): Effect.Effect< · 9 unmodified lines | ||
| 224 | 231 | return null; |
| 225 | 232 | } |
| 226 | 233 | |
| 227 | − return yield* Effect.fail( | |
| 228 | − new Error( | |
| 229 | − [ | |
| 230 | − `A local Executor ${manifest.kind} is registered at ${manifest.connection.origin} (pid ${manifest.pid}) but is not reachable.`, | |
| 231 | − "Refusing to start another local server against the same data directory.", | |
| 232 | − "Stop the existing process or remove the stale server-control manifest after verifying the process is not using the database.", | |
| 233 | − ].join("\n"), | |
| 234 | − ), | |
| 235 | − ); | |
| 234 | + return null; | |
| 236 | 235 | }); |
| 237 | 236 | |
| 237 | +const readActiveLocalServerManifest = readReachableLocalServerHint; | |
| 238 | + | |
| 238 | 239 | const normalizeDaemonScopeDir = (dir: string): string => { |
| 239 | 240 | const resolved = resolve(dir); |
| 240 | 241 | return existsSync(resolved) ? realpathSync.native(resolved) : resolved; |
| @@ -265,6 +266,48 @@ interface ExecuteCodeResult { · 24 unmodified lines | ||
| 265 | 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 | 311 | const parseDaemonUrl = (baseUrl: string) => |
| 269 | 312 | Effect.try({ |
| 270 | 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 | 498 | // Serialize daemon startup behind a filesystem lock so concurrent CLI invocations don't |
| 424 | 499 | // each spawn their own daemon. The post-lock pointer recheck catches the case where |
| 425 | 500 | // another invocation finished bootstrapping while we were waiting for the lock. |
| @@ -476,25 +551,21 @@ const spawnAndWaitForDaemon = (input: { · 50 unmodified lines | ||
| 476 | 551 | env: process.env, |
| 477 | 552 | }); |
| 478 | 553 | |
| 479 | − const ready = yield* waitForReachable({ | |
| 480 | − check: isServerReachable(startBaseUrl), | |
| 481 | − timeoutMs: DAEMON_BOOT_TIMEOUT_MS, | |
| 482 | − intervalMs: DAEMON_BOOT_POLL_MS, | |
| 483 | − }); | |
| 554 | + const readyBaseUrl = yield* waitForDaemonStartupTarget({ requestedBaseUrl: startBaseUrl }); | |
| 484 | 555 | |
| 485 | − if (!ready) { | |
| 556 | + if (!readyBaseUrl) { | |
| 486 | 557 | yield* terminateSpawnedDetachedProcess(child).pipe(Effect.ignore); |
| 487 | 558 | return yield* Effect.fail( |
| 488 | 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 | 562 | `Run in foreground to inspect logs: ${cliPrefix} daemon run --foreground --port ${selectedPort} --hostname ${input.host}`, |
| 492 | 563 | ].join("\n"), |
| 493 | 564 | ), |
| 494 | 565 | ); |
| 495 | 566 | } |
| 496 | 567 | |
| 497 | − return startBaseUrl; | |
| 568 | + return readyBaseUrl; | |
| 498 | 569 | } finally { |
| 499 | 570 | yield* releaseDaemonStartLock(lock).pipe(Effect.ignore); |
| 500 | 571 | } |
| @@ -950,15 +1021,18 @@ const runForegroundSession = (input: { · 449 unmodified lines | ||
| 950 | 1021 | |
| 951 | 1022 | try { |
| 952 | 1023 | yield* assertNoOtherActiveLocalServer(); |
| 953 | − server = yield* Effect.promise(() => | |
| 954 | − startServer({ | |
| 955 | − port: input.port, | |
| 956 | − hostname: input.hostname, | |
| 957 | − allowedHosts: input.allowedHosts, | |
| 958 | − authToken: input.authToken, | |
| 959 | − embeddedWebUI, | |
| 960 | − }), | |
| 961 | − ); | |
| 1024 | + const startResult = yield* startServerOrAttachOwnedDataDir({ | |
| 1025 | + port: input.port, | |
| 1026 | + hostname: input.hostname, | |
| 1027 | + allowedHosts: input.allowedHosts, | |
| 1028 | + authToken: input.authToken, | |
| 1029 | + embeddedWebUI, | |
| 1030 | + }); | |
| 1031 | + if (startResult.kind === "attached") { | |
| 1032 | + console.log(`Executor is already running at ${startResult.manifest.connection.origin}.`); | |
| 1033 | + return; | |
| 1034 | + } | |
| 1035 | + server = startResult.server; | |
| 962 | 1036 | baseUrl = `http://${displayHost}:${server.port}`; |
| 963 | 1037 | yield* publishLocalServerManifest({ |
| 964 | 1038 | kind: "foreground", |
| @@ -1074,15 +1148,21 @@ const runDaemonSession = (input: { · 109 unmodified lines | ||
| 1074 | 1148 | yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); |
| 1075 | 1149 | } |
| 1076 | 1150 | |
| 1077 | − server = yield* Effect.promise(() => | |
| 1078 | − startServer({ | |
| 1079 | − port: input.port, | |
| 1080 | − hostname: input.hostname, | |
| 1081 | − allowedHosts: input.allowedHosts, | |
| 1082 | − authToken: input.authToken, | |
| 1083 | − embeddedWebUI, | |
| 1084 | − }), | |
| 1085 | − ); | |
| 1151 | + const startResult = yield* startServerOrAttachOwnedDataDir({ | |
| 1152 | + port: input.port, | |
| 1153 | + hostname: input.hostname, | |
| 1154 | + allowedHosts: input.allowedHosts, | |
| 1155 | + authToken: input.authToken, | |
| 1156 | + embeddedWebUI, | |
| 1157 | + }); | |
| 1158 | + if (startResult.kind === "attached") { | |
| 1159 | + if (shouldEmitDesktopSidecarSentinels()) { | |
| 1160 | + console.log(DESKTOP_SIDECAR_ATTACHED_SENTINEL); | |
| 1161 | + } | |
| 1162 | + console.log(`Daemon already running at ${startResult.manifest.connection.origin}.`); | |
| 1163 | + return; | |
| 1164 | + } | |
| 1165 | + server = startResult.server; | |
| 1086 | 1166 | |
| 1087 | 1167 | daemonPort = server.port; |
| 1088 | 1168 | token = randomUUID(); |
| @@ -1120,6 +1200,9 @@ const runDaemonSession = (input: { · 31 unmodified lines | ||
| 1120 | 1200 | token, |
| 1121 | 1201 | }); |
| 1122 | 1202 | |
| 1203 | + if (shouldEmitDesktopSidecarSentinels()) { | |
| 1204 | + console.log(`${DESKTOP_SIDECAR_READY_SENTINEL}:${daemonPort}`); | |
| 1205 | + } | |
| 1123 | 1206 | console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`); |
| 1124 | 1207 | |
| 1125 | 1208 | yield* waitForShutdownSignal(); |
| @@ -1216,28 +1299,61 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 90 unmodified lines | ||
| 1216 | 1299 | |
| 1217 | 1300 | try { |
| 1218 | 1301 | yield* assertNoOtherActiveLocalServer(); |
| 1219 | − web = yield* Effect.promise(() => | |
| 1220 | − withStdoutReroutedToStderr(async () => { | |
| 1221 | − const host = "127.0.0.1"; | |
| 1222 | − const port = await Effect.runPromise( | |
| 1223 | − chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), | |
| 1224 | − ); | |
| 1225 | − const baseUrl = `http://localhost:${port}`; | |
| 1226 | − const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); | |
| 1227 | − | |
| 1228 | − try { | |
| 1229 | − const executor = await getExecutor(); | |
| 1230 | − const server = await startServer({ | |
| 1231 | − port, | |
| 1232 | − hostname: host, | |
| 1233 | − embeddedWebUI, | |
| 1234 | − }); | |
| 1235 | − const serverBaseUrl = `http://localhost:${server.port}`; | |
| 1236 | − return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; | |
| 1237 | − } catch (cause) { | |
| 1238 | − restoreWebBaseUrl(); | |
| 1239 | − throw cause; | |
| 1240 | − } | |
| 1302 | + web = yield* Effect.tryPromise({ | |
| 1303 | + try: () => | |
| 1304 | + withStdoutReroutedToStderr(async () => { | |
| 1305 | + const host = "127.0.0.1"; | |
| 1306 | + const port = await Effect.runPromise( | |
| 1307 | + chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), | |
| 1308 | + ); | |
| 1309 | + const baseUrl = `http://localhost:${port}`; | |
| 1310 | + const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); | |
| 1311 | + | |
| 1312 | + try { | |
| 1313 | + const executor = await getExecutor(); | |
| 1314 | + const server = await startServer({ | |
| 1315 | + port, | |
| 1316 | + hostname: host, | |
| 1317 | + embeddedWebUI, | |
| 1318 | + }); | |
| 1319 | + const serverBaseUrl = `http://localhost:${server.port}`; | |
| 1320 | + return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; | |
| 1321 | + } catch (cause) { | |
| 1322 | + restoreWebBaseUrl(); | |
| 1323 | + throw cause; | |
| 1324 | + } | |
| 1325 | + }), | |
| 1326 | + catch: (cause) => cause, | |
| 1327 | + }).pipe( | |
| 1328 | + Effect.catch((cause) => { | |
| 1329 | + const ownership = findDataDirOwnershipHeld(cause); | |
| 1330 | + if (!ownership) return Effect.fail(toError(cause)); | |
| 1331 | + return Effect.gen(function* () { | |
| 1332 | + const manifest = yield* readReachableLocalServerHint(); | |
| 1333 | + const path = yield* PlatformPath.Path; | |
| 1334 | + const dataDir = resolveExecutorDataDir(path); | |
| 1335 | + if (manifest) { | |
| 1336 | + return yield* Effect.fail( | |
| 1337 | + new Error( | |
| 1338 | + [ | |
| 1339 | + `A local Executor server already owns ${dataDir} at ${manifest.connection.origin}.`, | |
| 1340 | + "The stdio MCP server needs exclusive access to the local database.", | |
| 1341 | + `Use the running HTTP MCP endpoint instead: ${manifest.connection.origin}/mcp`, | |
| 1342 | + ].join("\n"), | |
| 1343 | + ), | |
| 1344 | + ); | |
| 1345 | + } | |
| 1346 | + return yield* Effect.fail( | |
| 1347 | + new Error( | |
| 1348 | + [ | |
| 1349 | + "Executor data directory is owned by another live process, but no reachable local server was advertised.", | |
| 1350 | + `Data directory: ${dataDir}`, | |
| 1351 | + `Ownership lock: ${ownership.lockPath}`, | |
| 1352 | + "Wait for the existing process to finish starting, or stop it and retry.", | |
| 1353 | + ].join("\n"), | |
| 1354 | + ), | |
| 1355 | + ); | |
| 1356 | + }); | |
| 1241 | 1357 | }), |
| 1242 | 1358 | ); |
| 1243 | 1359 | yield* publishLocalServerManifest({ |
| @@ -4,9 +4,10 @@ · 3 unmodified lines | ||
| 4 | 4 | * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`. |
| 5 | 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 | |
| 8 | − * The dev sidecar announces `EXECUTOR_READY:<port>`. The packaged CLI daemon | |
| 9 | − * announces `Daemon ready on http://host:port`. This controller accepts both. | |
| 7 | + * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN. | |
| 8 | + * The dev sidecar and packaged CLI child announce structured stdout sentinels | |
| 9 | + * (`EXECUTOR_READY:<port>` or `EXECUTOR_ATTACHED`). Human-readable log text is | |
| 10 | + * never part of the desktop startup contract. | |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import { spawn, type ChildProcess } from "node:child_process"; |
| @@ -35,6 +36,8 @@ const sidecarLog = log.scope("sidecar"); · 22 unmodified lines | ||
| 35 | 36 | // Rolling stderr tail attached to crash reports. Bounded so a chatty |
| 36 | 37 | // sidecar can't grow it unbounded over a long session. |
| 37 | 38 | const STDERR_TAIL_LIMIT = 8 * 1024; |
| 39 | +const READY_SENTINEL = "EXECUTOR_READY"; | |
| 40 | +const ATTACHED_SENTINEL = "EXECUTOR_ATTACHED"; | |
| 38 | 41 | |
| 39 | 42 | // Children deliberately stopped via stopSidecar (quit, restart, update) — |
| 40 | 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 | 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 | 140 | const readLockPid = (dataDir: string): number | null => { |
| 155 | 141 | const path = localServerStartLockPath(dataDir); |
| 156 | 142 | if (!existsSync(path)) return null; |
| @@ -277,6 +263,12 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 120 unmodified lines | ||
| 277 | 263 | const dataDir = scopeDir; |
| 278 | 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 | 272 | // The stable bearer token from auth.json (shared with the CLI). The main |
| 281 | 273 | // process holds it so it can inject the header into the webview; the child |
| 282 | 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 | 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 | |
| 308 | − try { | |
| 309 | − assertNoOtherLocalServerOwner(dataDir); | |
| 310 | − } catch (error) { | |
| 299 | + const attachedAfterLock = await attachToReachableLocalServer(dataDir); | |
| 300 | + if (attachedAfterLock) { | |
| 311 | 301 | releaseLock(); |
| 312 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock | |
| 313 | − throw error; | |
| 302 | + return attachedAfterLock; | |
| 314 | 303 | } |
| 315 | 304 | } |
| 316 | 305 | |
| @@ -348,6 +337,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 31 unmodified lines | ||
| 348 | 337 | |
| 349 | 338 | return new Promise<SidecarConnection>((resolveStart, rejectStart) => { |
| 350 | 339 | let stderrBuffer = ""; |
| 340 | + let stdoutControlBuffer = ""; | |
| 351 | 341 | let resolved = false; |
| 352 | 342 | let rejected = false; |
| 353 | 343 | |
| @@ -362,14 +352,33 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 8 unmodified lines | ||
| 362 | 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 | 366 | const onStdout = (chunk: Buffer) => { |
| 366 | 367 | const text = chunk.toString("utf8"); |
| 367 | 368 | process.stdout.write(`[executor-server] ${text}`); |
| 368 | 369 | logStdoutLine(text); |
| 369 | − const match = | |
| 370 | − text.match(/EXECUTOR_READY:(\d+)/) ?? | |
| 371 | − text.match(/Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/); | |
| 372 | − if (match && !resolved) { | |
| 370 | + stdoutControlBuffer += text; | |
| 371 | + const rawControlLines = stdoutControlBuffer.split(/\r?\n/); | |
| 372 | + stdoutControlBuffer = rawControlLines.pop() ?? ""; | |
| 373 | + const stdoutLines = rawControlLines | |
| 374 | + .map((line) => line.trim()) | |
| 375 | + .filter((line) => line.length > 0); | |
| 376 | + const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`)); | |
| 377 | + if (stdoutLines.includes(ATTACHED_SENTINEL) && !resolved) { | |
| 378 | + void resolveAttachedOwner(); | |
| 379 | + return; | |
| 380 | + } | |
| 381 | + if (readyLine && !resolved) { | |
| 373 | 382 | if (!child.pid) { |
| 374 | 383 | reject( |
| 375 | 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 | 387 | return; |
| 379 | 388 | } |
| 380 | 389 | resolved = true; |
| 381 | − const port = parseInt(match[1], 10); | |
| 390 | + const port = parseInt(readyLine.slice(`${READY_SENTINEL}:`.length), 10); | |
| 382 | 391 | const baseUrl = `http://${hostname}:${port}`; |
| 383 | 392 | if (!cliManagedManifest) { |
| 384 | 393 | writeSidecarManifest({ |
| @@ -428,15 +437,18 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 43 unmodified lines | ||
| 428 | 437 | return; |
| 429 | 438 | } |
| 430 | 439 | if (rejected) return; |
| 431 | − // Detect bind failure — the Node listener prints either "EADDRINUSE" or | |
| 432 | − // "address already in use" on stderr before exiting non-zero. | |
| 433 | − if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) { | |
| 434 | − reject(new SidecarPortInUseError(settings.port)); | |
| 435 | − return; | |
| 436 | − } | |
| 437 | − const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`; | |
| 438 | − // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise | |
| 439 | − reject(new Error(message)); | |
| 440 | + void (async () => { | |
| 441 | + if (await resolveAttachedOwner()) return; | |
| 442 | + // Detect bind failure — the Node listener prints either "EADDRINUSE" or | |
| 443 | + // "address already in use" on stderr before exiting non-zero. | |
| 444 | + if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) { | |
| 445 | + reject(new SidecarPortInUseError(settings.port)); | |
| 446 | + return; | |
| 447 | + } | |
| 448 | + const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`; | |
| 449 | + // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise | |
| 450 | + reject(new Error(message)); | |
| 451 | + })(); | |
| 440 | 452 | }; |
| 441 | 453 | |
| 442 | 454 | child.stdout?.on("data", onStdout); |
| @@ -468,6 +480,46 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 25 unmodified lines | ||
| 468 | 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 | 524 | * Attach to an already-running OS-supervised daemon instead of spawning our own |
| 473 | 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 | 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 | 76 | export const acquireDataDirOwnership = async (dataDir: string): Promise<DataDirOwnership> => { |
| 68 | 77 | const lockPath = ownerLockPath(dataDir); |
| 69 | 78 | const client = openOwnershipLockClient(lockPath); |
| @@ -18,12 +18,10 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 17 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | import executorConfig from "../executor.config"; |
| 20 | 20 | import { localDataMigrations } from "./db/data-migrations"; |
| 21 | −import { createSqliteFumaDb } from "./db/sqlite-fumadb"; | |
| 22 | −import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; | |
| 21 | +import { openOwnedLocalDatabase } from "./db/owned-database"; | |
| 23 | 22 | |
| 24 | 23 | interface ResolvedStorage { |
| 25 | 24 | readonly dataDir: string; |
| 26 | − readonly sqlitePath: string; | |
| 27 | 25 | } |
| 28 | 26 | |
| 29 | 27 | const localNamespace = "executor_local"; |
| @@ -36,10 +34,7 @@ const LOCAL_SUBJECT = "local"; · 6 unmodified lines | ||
| 36 | 34 | const resolveStorage = (): ResolvedStorage => { |
| 37 | 35 | const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); |
| 38 | 36 | fs.mkdirSync(dataDir, { recursive: true }); |
| 39 | − return { | |
| 40 | − dataDir, | |
| 41 | − sqlitePath: join(dataDir, "data.db"), | |
| 42 | − }; | |
| 37 | + return { dataDir }; | |
| 43 | 38 | }; |
| 44 | 39 | |
| 45 | 40 | // Hash suffix disambiguates same-basename folders so two projects with |
| @@ -151,28 +146,14 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 105 unmodified lines | ||
| 151 | 146 | const tenantId = makeTenantId(cwd); |
| 152 | 147 | const tables = collectTables(); |
| 153 | 148 | |
| 154 | − const migration = yield* Effect.tryPromise({ | |
| 155 | − try: () => | |
| 156 | − migrateLocalV1ToV2IfNeeded({ | |
| 157 | − sqlitePath: storage.sqlitePath, | |
| 158 | − tables, | |
| 159 | − namespace: localNamespace, | |
| 160 | − tenantId, | |
| 161 | − }), | |
| 162 | − catch: (cause) => | |
| 163 | − new LocalExecutorCreateError({ | |
| 164 | − message: CREATE_SQLITE_ERROR_MESSAGE, | |
| 165 | − cause, | |
| 166 | − }), | |
| 167 | − }); | |
| 168 | − | |
| 169 | − const sqlite = yield* Effect.acquireRelease( | |
| 149 | + const owned = yield* Effect.acquireRelease( | |
| 170 | 150 | Effect.tryPromise({ |
| 171 | 151 | try: () => |
| 172 | − createSqliteFumaDb({ | |
| 152 | + openOwnedLocalDatabase({ | |
| 153 | + dataDir: storage.dataDir, | |
| 173 | 154 | tables, |
| 174 | 155 | namespace: localNamespace, |
| 175 | − path: storage.sqlitePath, | |
| 156 | + tenantId, | |
| 176 | 157 | }), |
| 177 | 158 | catch: (cause) => |
| 178 | 159 | new LocalExecutorCreateError({ |
| @@ -180,8 +161,10 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 1 unmodified line | ||
| 180 | 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 | 169 | // Boot-time data migrations: each registry entry runs once and is |
| 187 | 170 | // stamped in the `data_migration` ledger; stamped entries are skipped |
| @@ -14,4 +14,9 @@ export { · 13 unmodified lines | ||
| 14 | 14 | } from "./executor"; |
| 15 | 15 | export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; |
| 16 | 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 | 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 | 3 | import { createExecutionEngine } from "@executor-js/execution"; |
| 4 | 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 5 | 5 | import { makeLocalApiHandler } from "./app"; |
| 6 | −import { createExecutorHandle, getExecutorBundle } from "./executor"; | |
| 6 | +import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; | |
| 7 | 7 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 8 | 8 | |
| 9 | 9 | // --------------------------------------------------------------------------- |
| @@ -35,57 +35,96 @@ export type ServerHandlers = { · 25 unmodified lines | ||
| 35 | 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 | 52 | const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { |
| 39 | 53 | await Effect.runPromise( |
| 40 | − Effect.all( | |
| 41 | − [ | |
| 42 | − Effect.tryPromise({ | |
| 43 | − try: () => handlers.api.dispose(), | |
| 44 | − catch: (cause) => cause, | |
| 45 | − }).pipe(Effect.ignore), | |
| 46 | − Effect.tryPromise({ | |
| 47 | − try: () => handlers.mcp.close(), | |
| 48 | − catch: (cause) => cause, | |
| 49 | − }).pipe(Effect.ignore), | |
| 50 | − ], | |
| 51 | − { concurrency: "unbounded" }, | |
| 52 | − ), | |
| 54 | + Effect.gen(function* () { | |
| 55 | + yield* Effect.all( | |
| 56 | + [ | |
| 57 | + ignoreDisposeFailure("api.dispose", () => handlers.api.dispose()), | |
| 58 | + ignoreDisposeFailure("mcp.close", () => handlers.mcp.close()), | |
| 59 | + ], | |
| 60 | + { concurrency: "unbounded" }, | |
| 61 | + ); | |
| 62 | + // The API/MCP handlers borrow the shared local executor handle. Release it | |
| 63 | + // after the surfaces are closed so server shutdown (and failed startup | |
| 64 | + // cleanup via disposeServerHandlers) releases the owned data-dir lock. | |
| 65 | + yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor()); | |
| 66 | + }), | |
| 53 | 67 | ); |
| 54 | 68 | }; |
| 55 | 69 | |
| 56 | 70 | export const createServerHandlers = async (token: string): Promise<ServerHandlers> => { |
| 57 | − // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The | |
| 58 | − // boot bearer token is the authoritative `/api` gate (see `identity.ts`). | |
| 59 | − const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token); | |
| 60 | − | |
| 61 | − // The in-process MCP server runs over the SAME boot executor, with its own | |
| 62 | − // engine instance (the browser-approval + stdio surface is local-only and not | |
| 63 | − // part of the shared API). Reuse the shared boot bundle so the MCP executor is | |
| 64 | − // byte-identical to the one the API serves. | |
| 65 | − const { executor } = await getExecutorBundle(); | |
| 66 | − const engine = createExecutionEngine({ | |
| 67 | − executor, | |
| 68 | − codeExecutor: makeQuickJsExecutor(), | |
| 69 | − }); | |
| 70 | − const mcp = createMcpRequestHandler({ | |
| 71 | − defaultConfig: { engine }, | |
| 72 | − createConfigForResource: async (resource) => { | |
| 73 | − if (resource.kind === "default") return { config: { engine } }; | |
| 74 | − const handle = await createExecutorHandle({ | |
| 75 | − activeToolkitSlug: resource.slug, | |
| 76 | − }); | |
| 77 | − const toolkitEngine = createExecutionEngine({ | |
| 78 | − executor: handle.executor, | |
| 79 | − codeExecutor: makeQuickJsExecutor(), | |
| 80 | − }); | |
| 81 | − return { | |
| 82 | − config: { engine: toolkitEngine }, | |
| 83 | − close: handle.dispose, | |
| 84 | − }; | |
| 85 | − }, | |
| 86 | − }); | |
| 87 | − | |
| 88 | − return { api: apiHandler, mcp }; | |
| 71 | + let apiHandler: ServerHandlers["api"] | null = null; | |
| 72 | + let mcp: McpRequestHandler | null = null; | |
| 73 | + | |
| 74 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: handler boot owns the shared executor; partial startup failure must release it before rethrowing | |
| 75 | + try { | |
| 76 | + // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The | |
| 77 | + // boot bearer token is the authoritative `/api` gate (see `identity.ts`). | |
| 78 | + apiHandler = await makeLocalApiHandler(token); | |
| 79 | + | |
| 80 | + // The in-process MCP server runs over the SAME boot executor, with its own | |
| 81 | + // engine instance (the browser-approval + stdio surface is local-only and not | |
| 82 | + // part of the shared API). Reuse the shared boot bundle so the MCP executor is | |
| 83 | + // byte-identical to the one the API serves. | |
| 84 | + const { executor } = await getExecutorBundle(); | |
| 85 | + const engine = createExecutionEngine({ | |
| 86 | + executor, | |
| 87 | + codeExecutor: makeQuickJsExecutor(), | |
| 88 | + }); | |
| 89 | + mcp = createMcpRequestHandler({ | |
| 90 | + defaultConfig: { engine }, | |
| 91 | + createConfigForResource: async (resource) => { | |
| 92 | + if (resource.kind === "default") return { config: { engine } }; | |
| 93 | + const handle = await createExecutorHandle({ | |
| 94 | + activeToolkitSlug: resource.slug, | |
| 95 | + }); | |
| 96 | + const toolkitEngine = createExecutionEngine({ | |
| 97 | + executor: handle.executor, | |
| 98 | + codeExecutor: makeQuickJsExecutor(), | |
| 99 | + }); | |
| 100 | + return { | |
| 101 | + config: { engine: toolkitEngine }, | |
| 102 | + close: handle.dispose, | |
| 103 | + }; | |
| 104 | + }, | |
| 105 | + }); | |
| 106 | + | |
| 107 | + return { api: apiHandler, mcp }; | |
| 108 | + } catch (cause) { | |
| 109 | + const partialApiHandler = apiHandler; | |
| 110 | + const partialMcp = mcp; | |
| 111 | + await Effect.runPromise( | |
| 112 | + Effect.gen(function* () { | |
| 113 | + yield* Effect.all( | |
| 114 | + [ | |
| 115 | + partialApiHandler | |
| 116 | + ? ignoreDisposeFailure("api.dispose", () => partialApiHandler.dispose()) | |
| 117 | + : Effect.void, | |
| 118 | + partialMcp ? ignoreDisposeFailure("mcp.close", () => partialMcp.close()) : Effect.void, | |
| 119 | + ], | |
| 120 | + { concurrency: "unbounded" }, | |
| 121 | + ); | |
| 122 | + yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor()); | |
| 123 | + }), | |
| 124 | + ); | |
| 125 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async factory must reject with the original handler boot failure after cleanup | |
| 126 | + throw cause; | |
| 127 | + } | |
| 89 | 128 | }; |
| 90 | 129 | |
| 91 | 130 | export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()( |
| @@ -120,9 +159,11 @@ export const disposeServerHandlers = async (): Promise<void> => { · 28 unmodified lines | ||
| 120 | 159 | if (!runtime) return; |
| 121 | 160 | serverHandlersRuntime = null; |
| 122 | 161 | await Effect.runPromise( |
| 123 | − Effect.tryPromise({ | |
| 124 | − try: () => runtime.dispose(), | |
| 125 | − catch: (cause) => cause, | |
| 126 | − }).pipe(Effect.ignore), | |
| 162 | + Effect.gen(function* () { | |
| 163 | + yield* ignoreDisposeFailure("runtime.dispose", () => runtime.dispose()); | |
| 164 | + // Belt-and-suspenders: runtime disposal normally reaches closeServerHandlers, | |
| 165 | + // but a failed runtime finalizer must not leave the shared DB owner alive. | |
| 166 | + yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor()); | |
| 167 | + }), | |
| 127 | 168 | ); |
| 128 | 169 | }; |
| @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | |
| 6 | +import { acquireDataDirOwnership } from "./db/data-dir-ownership"; | |
| 6 | 7 | import { startServer, type ServerInstance } from "./serve"; |
| 7 | 8 | |
| 8 | 9 | let clientDir: string; |
| @@ -40,8 +41,9 @@ const startTestServer = async ( · 31 unmodified lines | ||
| 40 | 41 | beforeEach(() => { |
| 41 | 42 | clientDir = mkdtempSync(join(tmpdir(), "exec-local-serve-")); |
| 42 | 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 | 45 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| 46 | + process.env.EXECUTOR_SCOPE_DIR = dataDir; | |
| 45 | 47 | mkdirSync(join(clientDir, "assets"), { recursive: true }); |
| 46 | 48 | writeFileSync( |
| 47 | 49 | join(clientDir, "index.html"), |
| @@ -56,6 +58,7 @@ afterEach(async () => { · 8 unmodified lines | ||
| 56 | 58 | server = null; |
| 57 | 59 | } |
| 58 | 60 | delete process.env.EXECUTOR_DATA_DIR; |
| 61 | + delete process.env.EXECUTOR_SCOPE_DIR; | |
| 59 | 62 | rmSync(clientDir, { recursive: true, force: true }); |
| 60 | 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 | 133 | describe("startServer bearer auth", () => { |
| 85 | 134 | it("mints and persists a 0600 auth.json when no token is supplied", async () => { |
| 86 | 135 | server = await startServer({ port: 0, clientDir, handlers: testHandlers() }); |
| @@ -15,7 +15,7 @@ import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/serve · 14 unmodified lines | ||
| 15 | 15 | import { loadOrMintLocalAuthToken } from "./auth"; |
| 16 | 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; |
| 17 | 17 | import { startIntegrationsRefresh } from "./integrations"; |
| 18 | −import { getServerHandlers } from "./main"; | |
| 18 | +import { disposeServerHandlers, getServerHandlers } from "./main"; | |
| 19 | 19 | import { |
| 20 | 20 | DEFAULT_ALLOWED_HOSTS, |
| 21 | 21 | hasFileExtension, |
| @@ -285,6 +285,16 @@ const withCorsHeaders = ( · 263 unmodified lines | ||
| 285 | 285 | const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response => |
| 286 | 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 | 298 | export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { |
| 289 | 299 | const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); |
| 290 | 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 | 314 | startIntegrationsRefresh(); |
| 305 | 315 | |
| 316 | + const ownsHandlers = opts.handlers === undefined; | |
| 306 | 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 | 318 | let viteChild: ViteChild | null = null; |
| 322 | 319 | |
| 323 | − const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; | |
| 324 | − if (devMode) { | |
| 325 | − console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); | |
| 326 | − viteChild = await startViteChild(); | |
| 327 | − // Diagnostic only — this is the internal vite port the daemon proxies to. | |
| 328 | − // It must NOT read as a destination: the URL to open is the `Open:` line the | |
| 329 | − // CLI prints (the daemon port, with ?_token). Hitting the vite port directly | |
| 330 | − // skips that bootstrap and lands on the auth gate. | |
| 331 | − console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`); | |
| 332 | − serveIndex = () => | |
| 333 | − // Unused when viteChild is non-null; defined so the type checker | |
| 334 | − // can keep `serveIndex` non-nullable. | |
| 335 | − new Response("vite not ready", { status: 503 }); | |
| 336 | − } else if (opts.embeddedWebUI) { | |
| 337 | − staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); | |
| 338 | − const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); | |
| 339 | − serveIndex = () => htmlResponse(indexFile); | |
| 340 | − } else { | |
| 341 | − staticRoutes = collectStaticRoutes(clientDir); | |
| 342 | − const indexFile = Bun.file(join(clientDir, "index.html")); | |
| 343 | − serveIndex = () => htmlResponse(indexFile); | |
| 344 | − } | |
| 345 | − | |
| 346 | − const server = Bun.serve({ | |
| 347 | − port, | |
| 348 | − hostname, | |
| 349 | − // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume | |
| 350 | − // can idle longer during human approval; `0` disables the socket timeout. | |
| 351 | − idleTimeout: 0, | |
| 352 | − routes: { ...staticRoutes }, | |
| 353 | − async fetch(req) { | |
| 354 | − const withCors = (response: Response): Response => | |
| 355 | − withCorsHeaders(req, response, corsAllowedHosts); | |
| 356 | − | |
| 357 | − if (req.method === "OPTIONS" && req.headers.has("origin")) { | |
| 358 | − return corsPreflightResponse(req, corsAllowedHosts); | |
| 359 | − } | |
| 360 | − | |
| 361 | − const url = new URL(req.url); | |
| 362 | − | |
| 363 | − // Unauthenticated liveness probe — carries no data, used by the CLI | |
| 364 | − // reachability check (which therefore never forwards a credential). | |
| 365 | − if (url.pathname === "/api/health" && req.method === "GET") { | |
| 366 | − return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); | |
| 367 | − } | |
| 368 | − | |
| 369 | − // OAuth callbacks and CIMD documents are reached by the external | |
| 370 | − // provider, which cannot carry our local bearer. Everything else under | |
| 371 | − // /api and /mcp requires the bearer. | |
| 372 | − const skipAuth = isUnauthenticatedOAuthPath(url.pathname); | |
| 373 | − const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); | |
| 374 | − const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath; | |
| 375 | − | |
| 376 | − if (isGatedSurface && !skipAuth && !isAuthorized(req)) { | |
| 377 | − return withCors( | |
| 378 | − new Response("Unauthorized", { | |
| 379 | − status: 401, | |
| 380 | − headers: { "www-authenticate": 'Bearer realm="executor"' }, | |
| 381 | − }), | |
| 382 | − ); | |
| 383 | − } | |
| 384 | − | |
| 385 | − if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") { | |
| 386 | − return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); | |
| 387 | − } | |
| 388 | − | |
| 389 | − if (isMcpPath) { | |
| 390 | − return withCors(await handlers.mcp.handleRequest(req)); | |
| 391 | − } | |
| 392 | − | |
| 393 | − if (url.pathname.startsWith("/api/mcp-sessions/")) { | |
| 394 | − // GET → paused-execution detail for the approval page; POST → record the | |
| 395 | − // decision. Both are bearer-gated above. | |
| 396 | − const handler = | |
| 397 | − req.method === "GET" | |
| 398 | − ? handlers.mcp.handlePausedRequest | |
| 399 | − : handlers.mcp.handleApprovalRequest; | |
| 400 | − return withCors(await handler(req)); | |
| 401 | − } | |
| 402 | − | |
| 403 | − // OAuth result polling — local-only, served outside the typed API | |
| 404 | − // because cloud (Cloudflare Workers, stateless) can't back the | |
| 405 | − // in-memory store. See setOAuthCompletionListener above. | |
| 406 | − const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); | |
| 407 | − if (awaitMatch && req.method === "GET") { | |
| 408 | − const result = consumeOAuthResult(awaitMatch[1]); | |
| 409 | − return withCors( | |
| 410 | − new Response(JSON.stringify(result), { | |
| 411 | − headers: { "content-type": "application/json" }, | |
| 412 | − }), | |
| 413 | − ); | |
| 414 | − } | |
| 415 | − | |
| 416 | − if (url.pathname.startsWith("/api/") || url.pathname === "/api") { | |
| 417 | − url.pathname = url.pathname.slice("/api".length) || "/"; | |
| 418 | − return withCors(await handlers.api.handler(new Request(url, req))); | |
| 419 | − } | |
| 420 | − | |
| 421 | − // Dev mode: forward everything else (SPA + hashed assets) to the | |
| 422 | − // vite child so source edits show up without a rebuild. | |
| 423 | − if (viteChild) { | |
| 424 | − return withCors(await proxyToVite(req, viteChild.url)); | |
| 425 | − } | |
| 426 | − | |
| 427 | − // If a path looks like a static asset (has a file extension), do not | |
| 428 | − // fall back to SPA HTML. Returning index.html here causes browser module | |
| 429 | − // MIME errors when hashed chunks are stale/missing. | |
| 430 | − if (hasFileExtension(url.pathname)) { | |
| 431 | − return withCors(new Response("Not Found", { status: 404 })); | |
| 432 | − } | |
| 320 | + const disposeOwnedResources = async (): Promise<void> => { | |
| 321 | + setOAuthCompletionListener(null); | |
| 322 | + if (ownsHandlers) { | |
| 323 | + await disposeServerHandlers(); | |
| 324 | + } else { | |
| 325 | + await closeProvidedHandlers(handlers); | |
| 326 | + } | |
| 327 | + if (viteChild) await viteChild.stop(); | |
| 328 | + }; | |
| 433 | 329 | |
| 434 | − // SPA fallback (unauthenticated — the browser loads the shell, then reads | |
| 435 | − // its token and sends the bearer on subsequent /api calls). | |
| 436 | − return withCors(await serveIndex()); | |
| 437 | − }, | |
| 438 | − error(error) { | |
| 439 | − console.error("Server error:", error); | |
| 440 | − return new Response("Internal Server Error", { status: 500 }); | |
| 441 | − }, | |
| 442 | − }); | |
| 330 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: after handlers boot, failed static/dev/Bun startup must release DB ownership before surfacing the startup error | |
| 331 | + try { | |
| 332 | + // Mirror every OAuth callback completion into the local in-memory result | |
| 333 | + // store. The Electron desktop renderer polls /api/oauth/await/:sessionId | |
| 334 | + // for these when the user runs the flow in their system browser (no | |
| 335 | + // shared origin → no postMessage). Cloud doesn't register a listener; | |
| 336 | + // its same-origin web SPA receives results via postMessage directly. | |
| 337 | + setOAuthCompletionListener((result) => publishOAuthResult(result)); | |
| 338 | + | |
| 339 | + // Build static routes from either embedded assets, disk, or a spawned | |
| 340 | + // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and | |
| 341 | + // disables the file-extension 404 short-circuit since vite serves | |
| 342 | + // hashed asset paths directly. | |
| 343 | + let staticRoutes: Record<string, StaticHandler> = {}; | |
| 344 | + let serveIndex: StaticHandler; | |
| 345 | + | |
| 346 | + const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; | |
| 347 | + if (devMode) { | |
| 348 | + console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); | |
| 349 | + viteChild = await startViteChild(); | |
| 350 | + // Diagnostic only — this is the internal vite port the daemon proxies to. | |
| 351 | + // It must NOT read as a destination: the URL to open is the `Open:` line the | |
| 352 | + // CLI prints (the daemon port, with ?_token). Hitting the vite port directly | |
| 353 | + // skips that bootstrap and lands on the auth gate. | |
| 354 | + console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`); | |
| 355 | + serveIndex = () => | |
| 356 | + // Unused when viteChild is non-null; defined so the type checker | |
| 357 | + // can keep `serveIndex` non-nullable. | |
| 358 | + new Response("vite not ready", { status: 503 }); | |
| 359 | + } else if (opts.embeddedWebUI) { | |
| 360 | + staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); | |
| 361 | + const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); | |
| 362 | + serveIndex = () => htmlResponse(indexFile); | |
| 363 | + } else { | |
| 364 | + staticRoutes = collectStaticRoutes(clientDir); | |
| 365 | + const indexFile = Bun.file(join(clientDir, "index.html")); | |
| 366 | + serveIndex = () => htmlResponse(indexFile); | |
| 367 | + } | |
| 443 | 368 | |
| 444 | − return { | |
| 445 | − port: server.port!, | |
| 446 | − authToken, | |
| 447 | − async stop() { | |
| 448 | − setOAuthCompletionListener(null); | |
| 449 | − server.stop(true); | |
| 450 | − await handlers.mcp.close(); | |
| 451 | − await handlers.api.dispose(); | |
| 452 | − if (viteChild) await viteChild.stop(); | |
| 453 | − }, | |
| 454 | − }; | |
| 369 | + const server = Bun.serve({ | |
| 370 | + port, | |
| 371 | + hostname, | |
| 372 | + // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume | |
| 373 | + // can idle longer during human approval; `0` disables the socket timeout. | |
| 374 | + idleTimeout: 0, | |
| 375 | + routes: { ...staticRoutes }, | |
| 376 | + async fetch(req) { | |
| 377 | + const withCors = (response: Response): Response => | |
| 378 | + withCorsHeaders(req, response, corsAllowedHosts); | |
| 379 | + | |
| 380 | + if (req.method === "OPTIONS" && req.headers.has("origin")) { | |
| 381 | + return corsPreflightResponse(req, corsAllowedHosts); | |
| 382 | + } | |
| 383 | + | |
| 384 | + const url = new URL(req.url); | |
| 385 | + | |
| 386 | + // Unauthenticated liveness probe — carries no data, used by the CLI | |
| 387 | + // reachability check (which therefore never forwards a credential). | |
| 388 | + if (url.pathname === "/api/health" && req.method === "GET") { | |
| 389 | + return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); | |
| 390 | + } | |
| 391 | + | |
| 392 | + // OAuth callbacks and CIMD documents are reached by the external | |
| 393 | + // provider, which cannot carry our local bearer. Everything else under | |
| 394 | + // /api and /mcp requires the bearer. | |
| 395 | + const skipAuth = isUnauthenticatedOAuthPath(url.pathname); | |
| 396 | + const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); | |
| 397 | + const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath; | |
| 398 | + | |
| 399 | + if (isGatedSurface && !skipAuth && !isAuthorized(req)) { | |
| 400 | + return withCors( | |
| 401 | + new Response("Unauthorized", { | |
| 402 | + status: 401, | |
| 403 | + headers: { "www-authenticate": 'Bearer realm="executor"' }, | |
| 404 | + }), | |
| 405 | + ); | |
| 406 | + } | |
| 407 | + | |
| 408 | + if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") { | |
| 409 | + return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); | |
| 410 | + } | |
| 411 | + | |
| 412 | + if (isMcpPath) { | |
| 413 | + return withCors(await handlers.mcp.handleRequest(req)); | |
| 414 | + } | |
| 415 | + | |
| 416 | + if (url.pathname.startsWith("/api/mcp-sessions/")) { | |
| 417 | + // GET → paused-execution detail for the approval page; POST → record the | |
| 418 | + // decision. Both are bearer-gated above. | |
| 419 | + const handler = | |
| 420 | + req.method === "GET" | |
| 421 | + ? handlers.mcp.handlePausedRequest | |
| 422 | + : handlers.mcp.handleApprovalRequest; | |
| 423 | + return withCors(await handler(req)); | |
| 424 | + } | |
| 425 | + | |
| 426 | + // OAuth result polling — local-only, served outside the typed API | |
| 427 | + // because cloud (Cloudflare Workers, stateless) can't back the | |
| 428 | + // in-memory store. See setOAuthCompletionListener above. | |
| 429 | + const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); | |
| 430 | + if (awaitMatch && req.method === "GET") { | |
| 431 | + const result = consumeOAuthResult(awaitMatch[1]); | |
| 432 | + return withCors( | |
| 433 | + new Response(JSON.stringify(result), { | |
| 434 | + headers: { "content-type": "application/json" }, | |
| 435 | + }), | |
| 436 | + ); | |
| 437 | + } | |
| 438 | + | |
| 439 | + if (url.pathname.startsWith("/api/") || url.pathname === "/api") { | |
| 440 | + url.pathname = url.pathname.slice("/api".length) || "/"; | |
| 441 | + return withCors(await handlers.api.handler(new Request(url, req))); | |
| 442 | + } | |
| 443 | + | |
| 444 | + // Dev mode: forward everything else (SPA + hashed assets) to the | |
| 445 | + // vite child so source edits show up without a rebuild. | |
| 446 | + if (viteChild) { | |
| 447 | + return withCors(await proxyToVite(req, viteChild.url)); | |
| 448 | + } | |
| 449 | + | |
| 450 | + // If a path looks like a static asset (has a file extension), do not | |
| 451 | + // fall back to SPA HTML. Returning index.html here causes browser module | |
| 452 | + // MIME errors when hashed chunks are stale/missing. | |
| 453 | + if (hasFileExtension(url.pathname)) { | |
| 454 | + return withCors(new Response("Not Found", { status: 404 })); | |
| 455 | + } | |
| 456 | + | |
| 457 | + // SPA fallback (unauthenticated — the browser loads the shell, then reads | |
| 458 | + // its token and sends the bearer on subsequent /api calls). | |
| 459 | + return withCors(await serveIndex()); | |
| 460 | + }, | |
| 461 | + error(error) { | |
| 462 | + console.error("Server error:", error); | |
| 463 | + return new Response("Internal Server Error", { status: 500 }); | |
| 464 | + }, | |
| 465 | + }); | |
| 466 | + | |
| 467 | + let stopped = false; | |
| 468 | + return { | |
| 469 | + port: server.port!, | |
| 470 | + authToken, | |
| 471 | + async stop() { | |
| 472 | + if (stopped) return; | |
| 473 | + stopped = true; | |
| 474 | + server.stop(true); | |
| 475 | + await disposeOwnedResources(); | |
| 476 | + }, | |
| 477 | + }; | |
| 478 | + } catch (cause) { | |
| 479 | + await ignoreCleanupFailure(disposeOwnedResources); | |
| 480 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async server start must reject with the original startup failure after cleanup | |
| 481 | + throw cause; | |
| 482 | + } | |
| 455 | 483 | } |
| 456 | 484 | |
| 457 | 485 | if (import.meta.main) { |