Unified · Split
| @@ -89,7 +89,7 @@ jobs: · 88 unmodified lines | |||
| 89 | - name: Build web app | 89 | - name: Build web app |
| 90 | run: bun run --filter @executor-js/local build | 90 | run: bun run --filter @executor-js/local build |
| 91 | 91 | ||
| 92 | - name: Build sidecar + stage web UI | 92 | - name: Build bundled executor |
| 93 | env: | 93 | env: |
| 94 | BUN_TARGET: bun-linux-x64 | 94 | BUN_TARGET: bun-linux-x64 |
| 95 | run: bun ./scripts/build-sidecar.ts | 95 | run: bun ./scripts/build-sidecar.ts |
| @@ -2,8 +2,8 @@ name: Publish Desktop App · 1 unmodified line | |||
| 2 | run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" | 2 | run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" |
| 3 | 3 | ||
| 4 | # Triggered manually or by publish-executor-package.yml after a CLI release | 4 | # Triggered manually or by publish-executor-package.yml after a CLI release |
| 5 | # lands. Builds Electron distributables for mac/win/linux with the Bun-compiled | 5 | # lands. Builds Electron distributables for mac/win/linux with the compiled |
| 6 | # sidecar bundled in `resources/sidecar/`, then attaches them to the GitHub | 6 | # executor CLI bundled in `resources/executor/`, then attaches them to the GitHub |
| 7 | # release matching the tag so electron-updater can pick them up. | 7 | # release matching the tag so electron-updater can pick them up. |
| 8 | 8 | ||
| 9 | on: | 9 | on: |
| @@ -97,17 +97,17 @@ jobs: · 87 unmodified lines | |||
| 97 | - name: Build web app | 97 | - name: Build web app |
| 98 | run: bun run --filter @executor-js/local build | 98 | run: bun run --filter @executor-js/local build |
| 99 | 99 | ||
| 100 | - name: Build sidecar binary + stage web UI | 100 | - name: Build bundled executor |
| 101 | env: | 101 | env: |
| 102 | BUN_TARGET: ${{ matrix.bun-target }} | 102 | BUN_TARGET: ${{ matrix.bun-target }} |
| 103 | run: bun ./scripts/build-sidecar.ts | 103 | run: bun ./scripts/build-sidecar.ts |
| 104 | working-directory: apps/desktop | 104 | working-directory: apps/desktop |
| 105 | 105 | ||
| 106 | # Gate the release on the compiled binary actually booting. v1.5.0/.1 | 106 | # Gate the release on the compiled binary actually booting. v1.5.0/.1 |
| 107 | # shipped sidecars that died on launch (missing libsql native binding) — | 107 | # shipped local-server binaries that died on launch (missing libsql native |
| 108 | # a regression dev mode can't catch because `bun run` resolves | 108 | # binding) — a regression dev mode can't catch because `bun run` resolves |
| 109 | # node_modules that `bun build --compile` does not bundle. | 109 | # node_modules that `bun build --compile` does not bundle. |
| 110 | - name: Smoke test compiled sidecar | 110 | - name: Smoke test bundled executor |
| 111 | if: matrix.smoke | 111 | if: matrix.smoke |
| 112 | run: bun run test:smoke | 112 | run: bun run test:smoke |
| 113 | working-directory: apps/desktop | 113 | working-directory: apps/desktop |
| @@ -30,6 +30,7 @@ · 29 unmodified lines | |||
| 30 | "@executor-js/runtime-quickjs": "workspace:*", | 30 | "@executor-js/runtime-quickjs": "workspace:*", |
| 31 | "@executor-js/sdk": "workspace:*", | 31 | "@executor-js/sdk": "workspace:*", |
| 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 33 | "@sentry/bun": "^10.57.0", | ||
| 33 | "effect": "catalog:", | 34 | "effect": "catalog:", |
| 34 | "quickjs-emscripten": "catalog:" | 35 | "quickjs-emscripten": "catalog:" |
| 35 | }, | 36 | }, |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | canAutoStartLocalDaemonForHost, | 7 | canAutoStartLocalDaemonForHost, |
| 8 | isDevCliEntrypoint, | 8 | isDevCliEntrypoint, |
| 9 | isExecutorServerReachable, | 9 | isExecutorServerReachable, |
| 10 | planServiceInstall, | ||
| 10 | } from "./daemon"; | 11 | } from "./daemon"; |
| 11 | 12 | ||
| 12 | describe("isDevCliEntrypoint", () => { | 13 | describe("isDevCliEntrypoint", () => { |
| @@ -93,3 +94,133 @@ describe("isExecutorServerReachable", () => { · 80 unmodified lines | |||
| 93 | }), | 94 | }), |
| 94 | ); | 95 | ); |
| 95 | }); | 96 | }); |
| 97 | |||
| 98 | describe("planServiceInstall", () => { | ||
| 99 | it("no-ops when the supervised service already runs this version on the requested port", () => { | ||
| 100 | expect( | ||
| 101 | planServiceInstall({ | ||
| 102 | registered: true, | ||
| 103 | running: true, | ||
| 104 | activeKind: "cli-daemon", | ||
| 105 | activeVersion: "1.5.11", | ||
| 106 | activePort: 4789, | ||
| 107 | requestedPort: 4789, | ||
| 108 | currentVersion: "1.5.11", | ||
| 109 | }), | ||
| 110 | ).toBe("noop"); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("reinstalls when the supervised service runs an older version", () => { | ||
| 114 | expect( | ||
| 115 | planServiceInstall({ | ||
| 116 | registered: true, | ||
| 117 | running: true, | ||
| 118 | activeKind: "cli-daemon", | ||
| 119 | activeVersion: "1.5.10", | ||
| 120 | activePort: 4789, | ||
| 121 | requestedPort: 4789, | ||
| 122 | currentVersion: "1.5.11", | ||
| 123 | }), | ||
| 124 | ).toBe("reinstall"); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("reinstalls when the version matches but the requested service port changed", () => { | ||
| 128 | expect( | ||
| 129 | planServiceInstall({ | ||
| 130 | registered: true, | ||
| 131 | running: true, | ||
| 132 | activeKind: "cli-daemon", | ||
| 133 | activeVersion: "1.5.11", | ||
| 134 | activePort: 4789, | ||
| 135 | requestedPort: 5790, | ||
| 136 | currentVersion: "1.5.11", | ||
| 137 | }), | ||
| 138 | ).toBe("reinstall"); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("reinstalls when the version and port match but the service points at another executable", () => { | ||
| 142 | expect( | ||
| 143 | planServiceInstall({ | ||
| 144 | registered: true, | ||
| 145 | running: true, | ||
| 146 | activeKind: "cli-daemon", | ||
| 147 | activeVersion: "1.5.11", | ||
| 148 | activeExecutablePath: | ||
| 149 | "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", | ||
| 150 | activePort: 4789, | ||
| 151 | requestedPort: 4789, | ||
| 152 | currentVersion: "1.5.11", | ||
| 153 | currentExecutablePath: "/Applications/Executor.app/Contents/Resources/executor/executor", | ||
| 154 | }), | ||
| 155 | ).toBe("reinstall"); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("takes over when a detached CLI daemon owns the manifest while the service is running elsewhere", () => { | ||
| 159 | expect( | ||
| 160 | planServiceInstall({ | ||
| 161 | registered: true, | ||
| 162 | running: true, | ||
| 163 | activeKind: "cli-daemon", | ||
| 164 | activePid: 2002, | ||
| 165 | servicePid: 1001, | ||
| 166 | activeVersion: "1.5.11", | ||
| 167 | activePort: 4788, | ||
| 168 | requestedPort: 55334, | ||
| 169 | currentVersion: "1.5.11", | ||
| 170 | }), | ||
| 171 | ).toBe("takeover-then-install"); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("reinstalls when the supervised service is up but manifest details are unavailable", () => { | ||
| 175 | expect( | ||
| 176 | planServiceInstall({ | ||
| 177 | registered: true, | ||
| 178 | running: true, | ||
| 179 | activeKind: null, | ||
| 180 | activeVersion: null, | ||
| 181 | activePort: null, | ||
| 182 | requestedPort: 4789, | ||
| 183 | currentVersion: "1.5.11", | ||
| 184 | }), | ||
| 185 | ).toBe("reinstall"); | ||
| 186 | }); | ||
| 187 | |||
| 188 | it("takes over when another local server kind owns the data directory", () => { | ||
| 189 | expect( | ||
| 190 | planServiceInstall({ | ||
| 191 | registered: true, | ||
| 192 | running: true, | ||
| 193 | activeKind: "foreground", | ||
| 194 | activeVersion: "1.5.11", | ||
| 195 | activePort: 4789, | ||
| 196 | requestedPort: 4789, | ||
| 197 | currentVersion: "1.5.11", | ||
| 198 | }), | ||
| 199 | ).toBe("takeover-then-install"); | ||
| 200 | expect( | ||
| 201 | planServiceInstall({ | ||
| 202 | registered: false, | ||
| 203 | running: false, | ||
| 204 | activeKind: "desktop-sidecar", | ||
| 205 | activeVersion: "1.5.10", | ||
| 206 | activePort: 4789, | ||
| 207 | requestedPort: 4789, | ||
| 208 | currentVersion: "1.5.11", | ||
| 209 | }), | ||
| 210 | ).toBe("takeover-then-install"); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it("takes over on a fresh install path before writing the service", () => { | ||
| 214 | expect( | ||
| 215 | planServiceInstall({ | ||
| 216 | registered: false, | ||
| 217 | running: false, | ||
| 218 | activeKind: null, | ||
| 219 | activeVersion: null, | ||
| 220 | activePort: null, | ||
| 221 | requestedPort: 4789, | ||
| 222 | currentVersion: "1.5.11", | ||
| 223 | }), | ||
| 224 | ).toBe("takeover-then-install"); | ||
| 225 | }); | ||
| 226 | }); | ||
| @@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: { · 281 unmodified lines | |||
| 282 | } | 282 | } |
| 283 | return fallbackPort; | 283 | return fallbackPort; |
| 284 | }); | 284 | }); |
| 285 | |||
| 286 | // --------------------------------------------------------------------------- | ||
| 287 | // Service-install planning (pure) | ||
| 288 | // --------------------------------------------------------------------------- | ||
| 289 | |||
| 290 | export type ServiceInstallPlan = "noop" | "reinstall" | "takeover-then-install"; | ||
| 291 | |||
| 292 | export const planServiceInstall = (input: { | ||
| 293 | readonly registered: boolean; | ||
| 294 | readonly running: boolean; | ||
| 295 | readonly activeKind: "cli-daemon" | "desktop-sidecar" | "foreground" | null; | ||
| 296 | readonly activePid?: number | null; | ||
| 297 | readonly servicePid?: number | null; | ||
| 298 | readonly activeVersion: string | null; | ||
| 299 | readonly activeExecutablePath?: string | null; | ||
| 300 | readonly activePort: number | null; | ||
| 301 | readonly requestedPort: number; | ||
| 302 | readonly currentVersion: string; | ||
| 303 | readonly currentExecutablePath?: string | null; | ||
| 304 | }): ServiceInstallPlan => { | ||
| 305 | if (input.activeKind !== null && input.activeKind !== "cli-daemon") { | ||
| 306 | return "takeover-then-install"; | ||
| 307 | } | ||
| 308 | |||
| 309 | if (input.registered && input.running) { | ||
| 310 | if ( | ||
| 311 | input.activeKind === "cli-daemon" && | ||
| 312 | input.activePid !== undefined && | ||
| 313 | input.activePid !== null && | ||
| 314 | input.servicePid !== undefined && | ||
| 315 | input.servicePid !== null && | ||
| 316 | input.activePid !== input.servicePid | ||
| 317 | ) { | ||
| 318 | return "takeover-then-install"; | ||
| 319 | } | ||
| 320 | |||
| 321 | const executableMatches = | ||
| 322 | !input.activeExecutablePath || | ||
| 323 | !input.currentExecutablePath || | ||
| 324 | input.activeExecutablePath === input.currentExecutablePath; | ||
| 325 | return input.activeVersion === input.currentVersion && | ||
| 326 | input.activePort === input.requestedPort && | ||
| 327 | executableMatches | ||
| 328 | ? "noop" | ||
| 329 | : "reinstall"; | ||
| 330 | } | ||
| 331 | |||
| 332 | return "takeover-then-install"; | ||
| 333 | }; | ||
| @@ -36,6 +36,25 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 35 unmodified lines | |||
| 36 | setQuickJSModule(mod); | 36 | setQuickJSModule(mod); |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | const sentryDsn = process.env.EXECUTOR_SENTRY_DSN; | ||
| 40 | if (sentryDsn) { | ||
| 41 | const Sentry = await import("@sentry/bun"); | ||
| 42 | Sentry.init({ | ||
| 43 | dsn: sentryDsn, | ||
| 44 | release: process.env.EXECUTOR_SENTRY_RELEASE, | ||
| 45 | environment: process.env.EXECUTOR_SENTRY_ENVIRONMENT ?? "production", | ||
| 46 | tracesSampleRate: 0, | ||
| 47 | initialScope: { | ||
| 48 | tags: { | ||
| 49 | process: "daemon", | ||
| 50 | platform: process.platform, | ||
| 51 | arch: process.arch, | ||
| 52 | ...(process.env.EXECUTOR_RUN_ID ? { runId: process.env.EXECUTOR_RUN_ID } : {}), | ||
| 53 | }, | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | |||
| 39 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; | 58 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 40 | import { BunRuntime, BunServices } from "@effect/platform-bun"; | 59 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 41 | import { HttpApiClient } from "effect/unstable/httpapi"; | 60 | import { HttpApiClient } from "effect/unstable/httpapi"; |
| @@ -71,6 +90,7 @@ import { · 29 unmodified lines | |||
| 71 | isExecutorServerReachable, | 90 | isExecutorServerReachable, |
| 72 | isDevCliEntrypoint, | 91 | isDevCliEntrypoint, |
| 73 | parseDaemonBaseUrl, | 92 | parseDaemonBaseUrl, |
| 93 | planServiceInstall, | ||
| 74 | spawnDetached, | 94 | spawnDetached, |
| 75 | waitForReachable, | 95 | waitForReachable, |
| 76 | waitForUnreachable, | 96 | waitForUnreachable, |
| @@ -100,7 +120,6 @@ import { · 23 unmodified lines | |||
| 100 | acquireLocalServerStartLock, | 120 | acquireLocalServerStartLock, |
| 101 | readLocalServerManifest, | 121 | readLocalServerManifest, |
| 102 | releaseLocalServerStartLock, | 122 | releaseLocalServerStartLock, |
| 103 | removeLocalServerManifest, | ||
| 104 | removeLocalServerManifestIfOwnedBy, | 123 | removeLocalServerManifestIfOwnedBy, |
| 105 | resolveExecutorDataDir, | 124 | resolveExecutorDataDir, |
| 106 | writeLocalServerManifest, | 125 | writeLocalServerManifest, |
| @@ -260,7 +279,7 @@ const makeLocalServerManifest = (input: { · 153 unmodified lines | |||
| 260 | scopeDir: currentScopeDirForManifest(), | 279 | scopeDir: currentScopeDirForManifest(), |
| 261 | connection: input.connection, | 280 | connection: input.connection, |
| 262 | owner: { | 281 | owner: { |
| 263 | client: "cli", | 282 | client: process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli", |
| 264 | version: CLI_VERSION, | 283 | version: CLI_VERSION, |
| 265 | executablePath: isDevMode ? (script ?? null) : process.execPath, | 284 | executablePath: isDevMode ? (script ?? null) : process.execPath, |
| 266 | }, | 285 | }, |
| @@ -286,6 +305,41 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< · 19 unmodified lines | |||
| 286 | ); | 305 | ); |
| 287 | }); | 306 | }); |
| 288 | 307 | ||
| 308 | const takeOverActiveLocalServer = (): Effect.Effect< | ||
| 309 | ExecutorLocalServerManifest | null, | ||
| 310 | Error, | ||
| 311 | FileSystem.FileSystem | PlatformPath.Path | ||
| 312 | > => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | const manifest = yield* readLocalServerManifest(); | ||
| 315 | if (!manifest) return null; | ||
| 316 | |||
| 317 | if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) { | ||
| 318 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); | ||
| 319 | return null; | ||
| 320 | } | ||
| 321 | |||
| 322 | yield* terminatePid(manifest.pid).pipe(Effect.ignore); | ||
| 323 | const stopped = yield* waitForUnreachable({ | ||
| 324 | check: isServerReachable(manifest.connection.origin), | ||
| 325 | timeoutMs: DAEMON_STOP_TIMEOUT_MS, | ||
| 326 | intervalMs: DAEMON_BOOT_POLL_MS, | ||
| 327 | }); | ||
| 328 | if (!stopped) { | ||
| 329 | return yield* Effect.fail( | ||
| 330 | new Error( | ||
| 331 | [ | ||
| 332 | `The existing Executor ${manifest.kind} at ${manifest.connection.origin} (pid ${manifest.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, | ||
| 333 | "Stop it manually and re-run.", | ||
| 334 | ].join("\n"), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | } | ||
| 338 | |||
| 339 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); | ||
| 340 | return manifest; | ||
| 341 | }); | ||
| 342 | |||
| 289 | const publishLocalServerManifest = (input: { | 343 | const publishLocalServerManifest = (input: { |
| 290 | readonly kind: ExecutorLocalServerKind; | 344 | readonly kind: ExecutorLocalServerKind; |
| 291 | readonly connection: ExecutorServerConnection; | 345 | readonly connection: ExecutorServerConnection; |
| @@ -897,7 +951,7 @@ const runDaemonSession = (input: { · 605 unmodified lines | |||
| 897 | // and crash-loop under KeepAlive. (Found by a real reboot test with | 951 | // and crash-loop under KeepAlive. (Found by a real reboot test with |
| 898 | // integration data in the DB.) | 952 | // integration data in the DB.) |
| 899 | if (process.env.EXECUTOR_SUPERVISED) { | 953 | if (process.env.EXECUTOR_SUPERVISED) { |
| 900 | yield* removeLocalServerManifest().pipe(Effect.ignore); | 954 | yield* takeOverActiveLocalServer().pipe(Effect.ignore); |
| 901 | } else { | 955 | } else { |
| 902 | yield* assertNoOtherActiveLocalServer(); | 956 | yield* assertNoOtherActiveLocalServer(); |
| 903 | } | 957 | } |
| @@ -907,15 +961,34 @@ const runDaemonSession = (input: { · 3 unmodified lines | |||
| 907 | if (existing) { | 961 | if (existing) { |
| 908 | const existingUrl = daemonBaseUrl(existing.hostname, existing.port); | 962 | const existingUrl = daemonBaseUrl(existing.hostname, existing.port); |
| 909 | if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { | 963 | if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { |
| 910 | return yield* Effect.fail( | 964 | if (process.env.EXECUTOR_SUPERVISED) { |
| 911 | new Error( | 965 | yield* terminatePid(existing.pid).pipe(Effect.ignore); |
| 912 | [ | 966 | const stopped = yield* waitForUnreachable({ |
| 913 | `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, | 967 | check: isServerReachable(existingUrl), |
| 914 | `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, | 968 | timeoutMs: DAEMON_STOP_TIMEOUT_MS, |
| 915 | `Stop it first: ${cliPrefix} daemon stop`, | 969 | intervalMs: DAEMON_BOOT_POLL_MS, |
| 916 | ].join("\n"), | 970 | }); |
| 917 | ), | 971 | if (!stopped) { |
| 918 | ); | 972 | return yield* Effect.fail( |
| 973 | new Error( | ||
| 974 | [ | ||
| 975 | `The existing daemon for scope ${scopeId} at ${existingUrl} (pid ${existing.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, | ||
| 976 | "Stop it manually and re-run.", | ||
| 977 | ].join("\n"), | ||
| 978 | ), | ||
| 979 | ); | ||
| 980 | } | ||
| 981 | } else { | ||
| 982 | return yield* Effect.fail( | ||
| 983 | new Error( | ||
| 984 | [ | ||
| 985 | `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, | ||
| 986 | `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, | ||
| 987 | `Stop it first: ${cliPrefix} daemon stop`, | ||
| 988 | ].join("\n"), | ||
| 989 | ), | ||
| 990 | ); | ||
| 991 | } | ||
| 919 | } | 992 | } |
| 920 | yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); | 993 | yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); |
| 921 | } | 994 | } |
| @@ -2125,6 +2198,17 @@ const mcpCommand = Command.make( · 1203 unmodified lines | |||
| 2125 | 2198 | ||
| 2126 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; | 2199 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; |
| 2127 | 2200 | ||
| 2201 | const portFromOrigin = (origin: string): number | null => { | ||
| 2202 | try { | ||
| 2203 | const url = new URL(origin); | ||
| 2204 | if (!url.port) return url.protocol === "https:" ? 443 : 80; | ||
| 2205 | const port = Number.parseInt(url.port, 10); | ||
| 2206 | return Number.isInteger(port) && port > 0 ? port : null; | ||
| 2207 | } catch { | ||
| 2208 | return null; | ||
| 2209 | } | ||
| 2210 | }; | ||
| 2211 | |||
| 2128 | const servicePortOption = () => | 2212 | const servicePortOption = () => |
| 2129 | Options.integer("port") | 2213 | Options.integer("port") |
| 2130 | .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) | 2214 | .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) |
| @@ -2164,26 +2248,36 @@ const installService = (port: number, commandName: string) => · 33 unmodified lines | |||
| 2164 | return; | 2248 | return; |
| 2165 | } | 2249 | } |
| 2166 | 2250 | ||
| 2167 | // Don't fight an already-running local server against the same data dir | 2251 | const status = yield* backend.status(); |
| 2168 | // (a desktop sidecar, a foreground `executor web`, or an existing daemon). | 2252 | const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); |
| 2169 | const active = yield* readActiveLocalServerManifest(); | 2253 | const plan = planServiceInstall({ |
| 2170 | if (active) { | 2254 | registered: status.registered, |
| 2171 | const status = yield* backend.status(); | 2255 | running: status.running, |
| 2172 | if (status.registered && status.running && active.kind === "cli-daemon") { | 2256 | activeKind: active?.kind ?? null, |
| 2257 | activePid: active?.pid ?? null, | ||
| 2258 | servicePid: status.pid, | ||
| 2259 | activeVersion: active?.owner.version ?? null, | ||
| 2260 | activeExecutablePath: active?.owner.executablePath ?? null, | ||
| 2261 | activePort: active ? portFromOrigin(active.connection.origin) : null, | ||
| 2262 | requestedPort: port, | ||
| 2263 | currentVersion: CLI_VERSION, | ||
| 2264 | currentExecutablePath: process.execPath, | ||
| 2265 | }); | ||
| 2266 | |||
| 2267 | if (plan === "noop") { | ||
| 2268 | const where = active ? ` at ${active.connection.origin} (pid ${active.pid})` : ""; | ||
| 2269 | console.log(`Executor background service is already running${where}.`); | ||
| 2270 | console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); | ||
| 2271 | return; | ||
| 2272 | } | ||
| 2273 | |||
| 2274 | if (plan === "takeover-then-install") { | ||
| 2275 | const replaced = yield* takeOverActiveLocalServer(); | ||
| 2276 | if (replaced) { | ||
| 2173 | console.log( | 2277 | console.log( |
| 2174 | `Executor background service is already running at ${active.connection.origin} (pid ${active.pid}).`, | 2278 | `Replacing running Executor ${replaced.kind} at ${replaced.connection.origin} (pid ${replaced.pid})...`, |
| 2175 | ); | 2279 | ); |
| 2176 | console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); | ||
| 2177 | return; | ||
| 2178 | } | 2280 | } |
| 2179 | return yield* Effect.fail( | ||
| 2180 | new Error( | ||
| 2181 | [ | ||
| 2182 | `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, | ||
| 2183 | `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run \`${command}\`.`, | ||
| 2184 | ].join("\n"), | ||
| 2185 | ), | ||
| 2186 | ); | ||
| 2187 | } | 2281 | } |
| 2188 | 2282 | ||
| 2189 | const path = yield* PlatformPath.Path; | 2283 | const path = yield* PlatformPath.Path; |
| @@ -13,12 +13,14 @@ describe("service unit generation", () => { · 12 unmodified lines | |||
| 13 | const launchdInput = { | 13 | const launchdInput = { |
| 14 | label: "sh.executor.daemon", | 14 | label: "sh.executor.daemon", |
| 15 | programArguments: [ | 15 | programArguments: [ |
| 16 | "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", | 16 | "/Applications/Executor.app/Contents/Resources/executor/executor", |
| 17 | "daemon", | 17 | "daemon", |
| 18 | "run", | 18 | "run", |
| 19 | "--foreground", | 19 | "--foreground", |
| 20 | "--port", | 20 | "--port", |
| 21 | "4789", | 21 | "4789", |
| 22 | "--hostname", | ||
| 23 | "127.0.0.1", | ||
| 22 | ], | 24 | ], |
| 23 | environment: { | 25 | environment: { |
| 24 | EXECUTOR_SUPERVISED: "1", | 26 | EXECUTOR_SUPERVISED: "1", |
| @@ -65,13 +67,24 @@ describe("service unit generation", () => { · 40 unmodified lines | |||
| 65 | 67 | ||
| 66 | it("renders a systemd --user unit with crash-only restart", () => { | 68 | it("renders a systemd --user unit with crash-only restart", () => { |
| 67 | const unit = generateSystemdUnit({ | 69 | const unit = generateSystemdUnit({ |
| 68 | execStart: ["/usr/local/bin/executor", "daemon", "run", "--foreground", "--port", "4789"], | 70 | execStart: [ |
| 71 | "/usr/local/bin/executor", | ||
| 72 | "daemon", | ||
| 73 | "run", | ||
| 74 | "--foreground", | ||
| 75 | "--port", | ||
| 76 | "4789", | ||
| 77 | "--hostname", | ||
| 78 | "127.0.0.1", | ||
| 79 | ], | ||
| 69 | environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, | 80 | environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, |
| 70 | workingDirectory: "/home/x/.executor", | 81 | workingDirectory: "/home/x/.executor", |
| 71 | stdoutPath: "/home/x/.executor/logs/daemon.log", | 82 | stdoutPath: "/home/x/.executor/logs/daemon.log", |
| 72 | stderrPath: "/home/x/.executor/logs/daemon.error.log", | 83 | stderrPath: "/home/x/.executor/logs/daemon.error.log", |
| 73 | }); | 84 | }); |
| 74 | expect(unit).toContain("ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789"); | 85 | expect(unit).toContain( |
| 86 | "ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789 --hostname 127.0.0.1", | ||
| 87 | ); | ||
| 75 | expect(unit).toContain("Restart=on-failure"); | 88 | expect(unit).toContain("Restart=on-failure"); |
| 76 | expect(unit).toContain("WantedBy=default.target"); | 89 | expect(unit).toContain("WantedBy=default.target"); |
| 77 | expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); | 90 | expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); |
| @@ -92,7 +105,7 @@ describe("service unit generation", () => { · 14 unmodified lines | |||
| 92 | expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); | 105 | expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); |
| 93 | expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); | 106 | expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); |
| 94 | expect(wrapper).toContain( | 107 | expect(wrapper).toContain( |
| 95 | '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789', | 108 | '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789 --hostname 127.0.0.1', |
| 96 | ); | 109 | ); |
| 97 | expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); | 110 | expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); |
| 98 | // No secret in the wrapper — the daemon reads the bearer from auth.json at boot. | 111 | // No secret in the wrapper — the daemon reads the bearer from auth.json at boot. |
| @@ -138,27 +138,48 @@ const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray<s · 137 unmodified lines | |||
| 138 | "--foreground", | 138 | "--foreground", |
| 139 | "--port", | 139 | "--port", |
| 140 | String(descriptor.port), | 140 | String(descriptor.port), |
| 141 | "--hostname", | ||
| 142 | "127.0.0.1", | ||
| 141 | ]; | 143 | ]; |
| 142 | 144 | ||
| 143 | const serviceEnvironment = ( | 145 | const serviceEnvironment = ( |
| 144 | descriptor: ServiceDescriptor, | 146 | descriptor: ServiceDescriptor, |
| 145 | dataDir: string, | 147 | dataDir: string, |
| 146 | ): Record<string, string> => ({ | 148 | ): Record<string, string> => { |
| 147 | // Marks the process as OS-supervised so the daemon resolves its bearer token | 149 | const passThroughKeys = [ |
| 148 | // from the durable 0600 auth.json (the secret is never in the unit itself). | 150 | "EXECUTOR_CLIENT", |
| 149 | EXECUTOR_SUPERVISED: "1", | 151 | "EXECUTOR_SENTRY_DSN", |
| 150 | // Pin the data dir explicitly: launchd/systemd give a minimal environment and | 152 | "EXECUTOR_SENTRY_RELEASE", |
| 151 | // we never want the daemon to fall back to a different home than the user's. | 153 | "EXECUTOR_SENTRY_ENVIRONMENT", |
| 152 | EXECUTOR_DATA_DIR: dataDir, | 154 | "EXECUTOR_RUN_ID", |
| 153 | // Stamp the installing version so `service status` can flag drift after an | 155 | ] as const; |
| 154 | // upgrade where the unit still points at an older binary path. | 156 | const passThrough = Object.fromEntries( |
| 155 | EXECUTOR_SERVICE_VERSION: descriptor.version, | 157 | passThroughKeys.flatMap((key) => { |
| 156 | // A launchd/systemd unit starts with a bare PATH — without the user's PATH | 158 | const value = process.env[key]; |
| 157 | // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may | 159 | return value ? [[key, value] as const] : []; |
| 158 | // shell out to. `service install` runs from the user's shell, so its own | 160 | }), |
| 159 | // PATH is the right one to bake in. (Reference: opencode shell-env capture.) | 161 | ); |
| 160 | ...(process.env.PATH ? { PATH: process.env.PATH } : {}), | 162 | |
| 161 | }); | 163 | return { |
| 164 | // Marks the process as OS-supervised so the daemon resolves its bearer token | ||
| 165 | // from the durable 0600 auth.json (the secret is never in the unit itself). | ||
| 166 | EXECUTOR_SUPERVISED: "1", | ||
| 167 | // Pin the data/scope dirs explicitly: launchd/systemd give a minimal | ||
| 168 | // environment and we never want the daemon to fall back to a different home | ||
| 169 | // or cwd than the user's singleton local service. | ||
| 170 | EXECUTOR_DATA_DIR: dataDir, | ||
| 171 | EXECUTOR_SCOPE_DIR: process.env.EXECUTOR_SCOPE_DIR ?? dataDir, | ||
| 172 | // Stamp the installing version so `service status` can flag drift after an | ||
| 173 | // upgrade where the unit still points at an older binary path. | ||
| 174 | EXECUTOR_SERVICE_VERSION: descriptor.version, | ||
| 175 | // A launchd/systemd unit starts with a bare PATH — without the user's PATH | ||
| 176 | // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may | ||
| 177 | // shell out to. `service install` runs from the user's shell, so its own | ||
| 178 | // PATH is the right one to bake in. (Reference: opencode shell-env capture.) | ||
| 179 | ...(process.env.PATH ? { PATH: process.env.PATH } : {}), | ||
| 180 | ...passThrough, | ||
| 181 | }; | ||
| 182 | }; | ||
| 162 | 183 | ||
| 163 | // --------------------------------------------------------------------------- | 184 | // --------------------------------------------------------------------------- |
| 164 | // macOS — launchd LaunchAgent (fully built) | 185 | // macOS — launchd LaunchAgent (fully built) |
| @@ -7,12 +7,12 @@ · 6 unmodified lines | |||
| 7 | <true/> | 7 | <true/> |
| 8 | <key>com.apple.security.cs.allow-unsigned-executable-memory</key> | 8 | <key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
| 9 | <true/> | 9 | <true/> |
| 10 | <!-- Electron reads NODE_OPTIONS / NODE_PATH at startup; the sidecar | 10 | <!-- Electron reads NODE_OPTIONS / NODE_PATH at startup; the bundled executor |
| 11 | reads EXECUTOR_* env vars. Hardened runtime would strip them | 11 | reads EXECUTOR_* env vars. Hardened runtime would strip them |
| 12 | without this entitlement. --> | 12 | without this entitlement. --> |
| 13 | <key>com.apple.security.cs.allow-dyld-environment-variables</key> | 13 | <key>com.apple.security.cs.allow-dyld-environment-variables</key> |
| 14 | <true/> | 14 | <true/> |
| 15 | <!-- The Bun-compiled executor-sidecar binary inside Resources/sidecar/ | 15 | <!-- The Bun-compiled executor binary inside Resources/executor/ |
| 16 | loads dylibs Apple's library validator doesn't recognize as part | 16 | loads dylibs Apple's library validator doesn't recognize as part |
| 17 | of the Electron bundle's signing chain. --> | 17 | of the Electron bundle's signing chain. --> |
| 18 | <key>com.apple.security.cs.disable-library-validation</key> | 18 | <key>com.apple.security.cs.disable-library-validation</key> |
| @@ -7,20 +7,15 @@ const config: Configuration = { · 6 unmodified lines | |||
| 7 | directories: { | 7 | directories: { |
| 8 | output: "dist", | 8 | output: "dist", |
| 9 | // Static build inputs live in build/ (icon.png, entitlements.mac.plist). | 9 | // Static build inputs live in build/ (icon.png, entitlements.mac.plist). |
| 10 | // Runtime resources staged at build time (sidecar binary, web-ui) live | 10 | // Runtime resources staged at build time (the bundled executor CLI binary) |
| 11 | // in resources/ and are wired in via `extraResources` below. | 11 | // live in resources/ and are wired in via `extraResources` below. |
| 12 | buildResources: "build", | 12 | buildResources: "build", |
| 13 | }, | 13 | }, |
| 14 | files: ["out/**/*", "package.json"], | 14 | files: ["out/**/*", "package.json"], |
| 15 | extraResources: [ | 15 | extraResources: [ |
| 16 | { | 16 | { |
| 17 | from: "resources/sidecar/", | 17 | from: "resources/executor/", |
| 18 | to: "sidecar/", | 18 | to: "executor/", |
| 19 | filter: ["**/*"], | ||
| 20 | }, | ||
| 21 | { | ||
| 22 | from: "resources/web-ui/", | ||
| 23 | to: "web-ui/", | ||
| 24 | filter: ["**/*"], | 19 | filter: ["**/*"], |
| 25 | }, | 20 | }, |
| 26 | ], | 21 | ], |
| @@ -29,7 +24,7 @@ const config: Configuration = { · 2 unmodified lines | |||
| 29 | // Do NOT pin `arch:` inside the target objects. The publish workflow's | 24 | // Do NOT pin `arch:` inside the target objects. The publish workflow's |
| 30 | // matrix passes `--arm64` / `--x64` per leg; a config-level arch list | 25 | // matrix passes `--arm64` / `--x64` per leg; a config-level arch list |
| 31 | // would override that flag and force every leg to build both archs from | 26 | // would override that flag and force every leg to build both archs from |
| 32 | // a single per-leg sidecar binary, shipping mismatched-arch DMGs (errno | 27 | // a single per-leg bundled executor binary, shipping mismatched-arch DMGs (errno |
| 33 | // -86 / EBADARCH on Apple Silicon). The CLI flag is the source of truth. | 28 | // -86 / EBADARCH on Apple Silicon). The CLI flag is the source of truth. |
| 34 | target: ["dmg", "zip"], | 29 | target: ["dmg", "zip"], |
| 35 | hardenedRuntime: true, | 30 | hardenedRuntime: true, |
| @@ -45,10 +40,10 @@ const config: Configuration = { · 9 unmodified lines | |||
| 45 | }, | 40 | }, |
| 46 | // Same arch rule as mac (see comment above): never pin `arch:` in the | 41 | // Same arch rule as mac (see comment above): never pin `arch:` in the |
| 47 | // target objects. The win/linux pins used to force both archs out of a | 42 | // target objects. The win/linux pins used to force both archs out of a |
| 48 | // single x64 matrix leg, embedding an x64 sidecar binary inside the | 43 | // single x64 matrix leg, embedding an x64 executor binary inside the |
| 49 | // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each | 44 | // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each |
| 50 | // workflow leg's --x64/--arm64 flag decides what gets built, so an arm64 | 45 | // workflow leg's --x64/--arm64 flag decides what gets built, so an arm64 |
| 51 | // artifact only exists once a leg stages an arm64 sidecar for it. | 46 | // artifact only exists once a leg stages an arm64 executor for it. |
| 52 | win: { | 47 | win: { |
| 53 | target: ["nsis"], | 48 | target: ["nsis"], |
| 54 | }, | 49 | }, |
| @@ -1,5 +1,5 @@ | |||
| 1 | // Unsigned packaging config for e2e: produces the SAME app bundle as the | 1 | // Unsigned packaging config for e2e: produces the SAME app bundle as the |
| 2 | // release config (same extraResources sidecar binary + web-ui, same main/ | 2 | // release config (same bundled executor extraResource, same main/ |
| 3 | // preload `out/`), but skips Apple signing/notarization so it builds with no | 3 | // preload `out/`), but skips Apple signing/notarization so it builds with no |
| 4 | // CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through | 4 | // CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through |
| 5 | // Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is | 5 | // Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | "scripts": { | 9 | "scripts": { |
| 10 | "predev": "bun run --filter @executor-js/local build", | 10 | "predev": "bun run --filter @executor-js/local build", |
| 11 | "dev": "electron-vite dev", | 11 | "dev": "electron-vite dev", |
| 12 | "prebuild": "bun run --filter @executor-js/local build && bun ./scripts/build-sidecar.ts", | 12 | "prebuild": "bun ./scripts/build-sidecar.ts", |
| 13 | "build": "electron-vite build", | 13 | "build": "electron-vite build", |
| 14 | "preview": "electron-vite preview", | 14 | "preview": "electron-vite preview", |
| 15 | "package": "electron-builder --config electron-builder.config.ts", | 15 | "package": "electron-builder --config electron-builder.config.ts", |
| @@ -1,258 +1,52 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * Build the production sidecar binary using `bun build --compile`. | 2 | * Stage the production local-server binary for the packaged desktop app. |
| 3 | * | ||
| 4 | * Produces a fully self-contained executable that includes the Bun runtime | ||
| 5 | * plus the entire @executor-js/local server graph (including bun:sqlite, | ||
| 6 | * FumaDB, MCP, etc.). The Electron main process exec's this binary at | ||
| 7 | * runtime instead of relying on a `bun` install on the user's machine. | ||
| 8 | * | ||
| 9 | * Also stages the apps/local Vite build output as `resources/web-ui/` so | ||
| 10 | * electron-builder picks it up via extraResources. | ||
| 11 | * | 3 | * |
| 4 | * Packaged desktop uses the same compiled `executor` CLI binary as npm installs: | ||
| 5 | * the app delegates service install/status/restart to it, and the foreground | ||
| 6 | * fallback starts `executor daemon run --foreground`. | ||
| 12 | */ | 7 | */ |
| 13 | import { mkdir, rm, cp, writeFile } from "node:fs/promises"; | 8 | import { chmod, cp, mkdir, rm } from "node:fs/promises"; |
| 14 | import { existsSync } from "node:fs"; | 9 | import { resolve, join } from "node:path"; |
| 15 | import { createRequire } from "node:module"; | ||
| 16 | import { dirname, join, resolve } from "node:path"; | ||
| 17 | import { $ } from "bun"; | ||
| 18 | 10 | ||
| 19 | const ROOT = resolve(import.meta.dir, ".."); | 11 | const ROOT = resolve(import.meta.dir, ".."); |
| 20 | const REPO_ROOT = resolve(ROOT, "../.."); | 12 | const REPO_ROOT = resolve(ROOT, "../.."); |
| 21 | const APPS_LOCAL = resolve(REPO_ROOT, "apps/local"); | 13 | const CLI_ROOT = resolve(REPO_ROOT, "apps/cli"); |
| 22 | const SIDECAR_ENTRY = resolve(ROOT, "src/sidecar/server.ts"); | 14 | const EXECUTOR_OUT_DIR = resolve(ROOT, "resources/executor"); |
| 23 | const SIDECAR_OUT_DIR = resolve(ROOT, "resources/sidecar"); | 15 | |
| 24 | const WEB_UI_OUT_DIR = resolve(ROOT, "resources/web-ui"); | 16 | const platformName = (platform: NodeJS.Platform): string => |
| 25 | const APPS_LOCAL_DIST = resolve(APPS_LOCAL, "dist"); | 17 | platform === "win32" ? "windows" : platform; |
| 26 | const EMBEDDED_MIGRATIONS_PATH = resolve(APPS_LOCAL, "src/db/embedded-migrations.gen.ts"); | 18 | |
| 27 | const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record<string, string> | null = null;\n\nexport default migrations;\n`; | 19 | const currentTargetPackage = (): string => |
| 28 | 20 | `executor-${platformName(process.platform)}-${process.arch}`; | |
| 29 | /** | 21 | |
| 30 | * Cross-compile target for `bun build --compile`. When unset we use Bun's | 22 | const targetPackageFromBunTarget = (target: string | undefined): string | null => { |
| 31 | * default `bun` target (the runner's own platform). CI passes a specific | 23 | if (!target || target === "bun") return null; |
| 32 | * value like `bun-darwin-x64` to produce binaries for other platforms from | 24 | const normalized = target |
| 33 | * a single matrix entry. | 25 | .replace(/^bun-/, "") |
| 34 | */ | 26 | .replace(/^windows-/, "windows-") |
| 35 | const BUN_TARGET = process.env.BUN_TARGET ?? "bun"; | 27 | .replace(/^win32-/, "windows-"); |
| 36 | const targetIsWindows = BUN_TARGET.includes("windows") || process.platform === "win32"; | 28 | return `executor-${normalized}`; |
| 37 | const binaryName = targetIsWindows ? "executor-sidecar.exe" : "executor-sidecar"; | ||
| 38 | const sidecarBinary = resolve(SIDECAR_OUT_DIR, binaryName); | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Normalized `<os>-<arch>[-<abi>]` key for the compile target, derived from | ||
| 42 | * BUN_TARGET (`bun` = the runner's own platform). Matches the keys used by | ||
| 43 | * apps/cli/src/build.ts's native-binding maps. | ||
| 44 | */ | ||
| 45 | const targetKey = | ||
| 46 | BUN_TARGET === "bun" | ||
| 47 | ? `${process.platform}-${process.arch}` | ||
| 48 | : BUN_TARGET.replace(/^bun-/, "").replace(/^windows-/, "win32-"); | ||
| 49 | |||
| 50 | const targetIsCurrentPlatform = targetKey === `${process.platform}-${process.arch}`; | ||
| 51 | |||
| 52 | // `bun build --compile` does not bundle `.node` native addons into bunfs, so | ||
| 53 | // the sidecar's eager `require('@libsql/<plat>')` (and the keychain plugin's | ||
| 54 | // lazy keyring load) would fail at runtime. We stage each binding next to the | ||
| 55 | // binary; src/sidecar/native-bindings.ts (the sidecar's first import) points | ||
| 56 | // the loaders at them via EXECUTOR_LIBSQL_NATIVE_PATH / | ||
| 57 | // EXECUTOR_KEYRING_NATIVE_PATH. Mirrors apps/cli/src/build.ts. | ||
| 58 | const LIBSQL_NATIVE_VERSION = "0.5.29"; | ||
| 59 | const resolveLibsqlNative = (): string => { | ||
| 60 | const platformMap: Record<string, string> = { | ||
| 61 | "darwin-arm64": "darwin-arm64", | ||
| 62 | "darwin-x64": "darwin-x64", | ||
| 63 | // The compiled binary runs on Bun, which libSQL's loader treats as glibc | ||
| 64 | // (its musl->gnu workaround), so non-musl linux targets need the -gnu binding. | ||
| 65 | "linux-arm64": "linux-arm64-gnu", | ||
| 66 | "linux-x64": "linux-x64-gnu", | ||
| 67 | "win32-arm64": "win32-arm64-msvc", | ||
| 68 | "win32-x64": "win32-x64-msvc", | ||
| 69 | }; | ||
| 70 | const target = platformMap[targetKey]; | ||
| 71 | if (!target) { | ||
| 72 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 73 | throw new Error(`No @libsql native binding mapping for target ${targetKey}`); | ||
| 74 | } | ||
| 75 | const pkg = `@libsql/${target}`; | ||
| 76 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout | ||
| 77 | try { | ||
| 78 | const req = createRequire(join(APPS_LOCAL, "package.json")); | ||
| 79 | return join(dirname(req.resolve(`${pkg}/package.json`)), "index.node"); | ||
| 80 | } catch { | ||
| 81 | const bunPath = join( | ||
| 82 | REPO_ROOT, | ||
| 83 | `node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`, | ||
| 84 | ); | ||
| 85 | if (!existsSync(bunPath)) { | ||
| 86 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 87 | throw new Error( | ||
| 88 | `Cannot resolve ${pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | return bunPath; | ||
| 92 | } | ||
| 93 | }; | ||
| 94 | |||
| 95 | const resolveKeyringNative = (): string => { | ||
| 96 | const platformMap: Record<string, { pkg: string; node: string }> = { | ||
| 97 | "darwin-arm64": { | ||
| 98 | pkg: "@napi-rs/keyring-darwin-arm64", | ||
| 99 | node: "keyring.darwin-arm64.node", | ||
| 100 | }, | ||
| 101 | "darwin-x64": { | ||
| 102 | pkg: "@napi-rs/keyring-darwin-x64", | ||
| 103 | node: "keyring.darwin-x64.node", | ||
| 104 | }, | ||
| 105 | "linux-arm64": { | ||
| 106 | pkg: "@napi-rs/keyring-linux-arm64-gnu", | ||
| 107 | node: "keyring.linux-arm64-gnu.node", | ||
| 108 | }, | ||
| 109 | "linux-x64": { | ||
| 110 | pkg: "@napi-rs/keyring-linux-x64-gnu", | ||
| 111 | node: "keyring.linux-x64-gnu.node", | ||
| 112 | }, | ||
| 113 | "win32-arm64": { | ||
| 114 | pkg: "@napi-rs/keyring-win32-arm64-msvc", | ||
| 115 | node: "keyring.win32-arm64-msvc.node", | ||
| 116 | }, | ||
| 117 | "win32-x64": { | ||
| 118 | pkg: "@napi-rs/keyring-win32-x64-msvc", | ||
| 119 | node: "keyring.win32-x64-msvc.node", | ||
| 120 | }, | ||
| 121 | }; | ||
| 122 | const entry = platformMap[targetKey]; | ||
| 123 | if (!entry) { | ||
| 124 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 125 | throw new Error(`No @napi-rs/keyring native binding mapping for target ${targetKey}`); | ||
| 126 | } | ||
| 127 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout | ||
| 128 | try { | ||
| 129 | const req = createRequire(join(REPO_ROOT, "node_modules", "@napi-rs/keyring", "package.json")); | ||
| 130 | return join(dirname(req.resolve(`${entry.pkg}/package.json`)), entry.node); | ||
| 131 | } catch { | ||
| 132 | const bunPath = join( | ||
| 133 | REPO_ROOT, | ||
| 134 | `node_modules/.bun/${entry.pkg.replace("/", "+")}@1.2.0/node_modules/${entry.pkg}/${entry.node}`, | ||
| 135 | ); | ||
| 136 | if (!existsSync(bunPath)) { | ||
| 137 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 138 | throw new Error( | ||
| 139 | `Cannot resolve ${entry.pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | return bunPath; | ||
| 143 | } | ||
| 144 | }; | ||
| 145 | |||
| 146 | // QuickJS ships its WASM as a side asset; `bun build --compile` can't pull | ||
| 147 | // it into bunfs, so we stage it next to the binary and the sidecar entry | ||
| 148 | // preloads it via `setQuickJSModule` before any server import. | ||
| 149 | const resolveQuickJsWasmPath = (): string => { | ||
| 150 | const req = createRequire(join(REPO_ROOT, "packages/kernel/runtime-quickjs/package.json")); | ||
| 151 | const quickJsPkg = req.resolve("quickjs-emscripten/package.json"); | ||
| 152 | const wasmPath = resolve( | ||
| 153 | dirname(quickJsPkg), | ||
| 154 | "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", | ||
| 155 | ); | ||
| 156 | if (!existsSync(wasmPath)) { | ||
| 157 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 158 | throw new Error(`QuickJS WASM not found at ${wasmPath}`); | ||
| 159 | } | ||
| 160 | return wasmPath; | ||
| 161 | }; | 29 | }; |
| 162 | 30 | ||
| 163 | // The v1→v2 data migration replays the legacy v1 drizzle chain | 31 | const targetPackage = targetPackageFromBunTarget(process.env.BUN_TARGET) ?? currentTargetPackage(); |
| 164 | // (apps/local/drizzle-legacy-v1) before reading a legacy database. The | 32 | const targetArgs = process.env.BUN_TARGET ? ["--target", targetPackage] : ["--single"]; |
| 165 | // compiled sidecar cannot rely on that folder existing on disk, so inline | ||
| 166 | // every migration as text and let apps/local extract them to a temp folder | ||
| 167 | // during startup. Mirrors apps/cli/src/build.ts — embedding the wrong dir | ||
| 168 | // (e.g. the v2 chain in drizzle/) makes the sidecar treat every real legacy | ||
| 169 | // database as "history does not match" and skip the replay. | ||
| 170 | const createEmbeddedMigrationsSource = async () => { | ||
| 171 | const migrationsDir = resolve(APPS_LOCAL, "drizzle-legacy-v1"); | ||
| 172 | const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) | ||
| 173 | .map((file) => file.replaceAll("\\", "/")) | ||
| 174 | .sort(); | ||
| 175 | |||
| 176 | const imports = files.map((file, index) => { | ||
| 177 | const spec = join(migrationsDir, file).replaceAll("\\", "/"); | ||
| 178 | return `import file_${index} from ${JSON.stringify(spec)} with { type: "text" };`; | ||
| 179 | }); | ||
| 180 | |||
| 181 | const entries = files.map((file, index) => ` ${JSON.stringify(file)}: file_${index},`); | ||
| 182 | |||
| 183 | return [ | ||
| 184 | "// Auto-generated - maps migration paths to inlined file contents", | ||
| 185 | ...imports, | ||
| 186 | "export default {", | ||
| 187 | ...entries, | ||
| 188 | "} as Record<string, string>;", | ||
| 189 | ].join("\n"); | ||
| 190 | }; | ||
| 191 | |||
| 192 | if (!existsSync(APPS_LOCAL_DIST)) { | ||
| 193 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 194 | throw new Error( | ||
| 195 | `apps/local/dist not found. Run \`bun run --filter @executor-js/local build\` first.`, | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | 33 | ||
| 199 | // Cross-target builds (e.g. the mac x64 leg on an arm64 runner) need the other | 34 | console.log(`[build-sidecar] building CLI binary target ${targetPackage}`); |
| 200 | // platform's optional native packages on disk before we can stage them. | 35 | const build = Bun.spawn(["bun", "run", "src/build.ts", "binary", ...targetArgs], { |
| 201 | // `--cpu=* --os=*` extracts them all without modifying the lockfile. Mirrors | 36 | cwd: CLI_ROOT, |
| 202 | // apps/cli/src/build.ts — Bun.spawn, not Bun.$, because the shell | 37 | stdio: ["ignore", "inherit", "inherit"], |
| 203 | // glob-expands the bare `*` in `--cpu=*` and fails with "no matches found". | 38 | }); |
| 204 | if (!targetIsCurrentPlatform) { | 39 | if ((await build.exited) !== 0) { |
| 205 | console.log("[build-sidecar] installing optional native deps for all platforms..."); | 40 | throw new Error(`CLI binary build failed for ${targetPackage}`); |
| 206 | // timeout: bun install has been observed to print a fatal error (tarball | ||
| 207 | // integrity check) and then hang instead of exiting, wedging the CI leg | ||
| 208 | // until the job-level deadline. A healthy run takes well under a minute. | ||
| 209 | const proc = Bun.spawn(["bun", "install", "--frozen-lockfile", "--cpu=*", "--os=*"], { | ||
| 210 | cwd: REPO_ROOT, | ||
| 211 | stdio: ["ignore", "inherit", "inherit"], | ||
| 212 | timeout: 10 * 60 * 1000, | ||
| 213 | killSignal: "SIGKILL", | ||
| 214 | }); | ||
| 215 | if ((await proc.exited) !== 0) { | ||
| 216 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal | ||
| 217 | throw new Error("bun install --cpu=* --os=* failed (or timed out after 10 minutes)"); | ||
| 218 | } | ||
| 219 | } | 41 | } |
| 220 | 42 | ||
| 221 | // Resolve the native bindings up front so a missing platform package fails the | 43 | const sourceBinDir = join(CLI_ROOT, "dist", targetPackage, "bin"); |
| 222 | // build before the (slow) compile, and cross-target builds get a clear message. | 44 | await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true }); |
| 223 | const libsqlNativePath = resolveLibsqlNative(); | 45 | await mkdir(EXECUTOR_OUT_DIR, { recursive: true }); |
| 224 | const keyringNativePath = resolveKeyringNative(); | 46 | await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true }); |
| 225 | |||
| 226 | await rm(SIDECAR_OUT_DIR, { recursive: true, force: true }); | ||
| 227 | await rm(WEB_UI_OUT_DIR, { recursive: true, force: true }); | ||
| 228 | await mkdir(SIDECAR_OUT_DIR, { recursive: true }); | ||
| 229 | await mkdir(WEB_UI_OUT_DIR, { recursive: true }); | ||
| 230 | |||
| 231 | console.log( | ||
| 232 | `[build-sidecar] bun build --compile --target=${BUN_TARGET} ${SIDECAR_ENTRY} → ${sidecarBinary}`, | ||
| 233 | ); | ||
| 234 | |||
| 235 | console.log("[build-sidecar] generating embedded drizzle migrations"); | ||
| 236 | const embeddedMigrations = await createEmbeddedMigrationsSource(); | ||
| 237 | await writeFile(EMBEDDED_MIGRATIONS_PATH, `${embeddedMigrations}\n`); | ||
| 238 | |||
| 239 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time script must restore the checked-in migration stub after compile failure | ||
| 240 | try { | ||
| 241 | await $`bun build --compile --minify --sourcemap --target=${BUN_TARGET} --outfile ${sidecarBinary} ${SIDECAR_ENTRY}`.cwd( | ||
| 242 | REPO_ROOT, | ||
| 243 | ); | ||
| 244 | |||
| 245 | console.log(`[build-sidecar] staging QuickJS WASM → ${SIDECAR_OUT_DIR}`); | ||
| 246 | await cp(resolveQuickJsWasmPath(), join(SIDECAR_OUT_DIR, "emscripten-module.wasm")); | ||
| 247 | |||
| 248 | console.log(`[build-sidecar] staging native bindings (${targetKey}) → ${SIDECAR_OUT_DIR}`); | ||
| 249 | await cp(libsqlNativePath, join(SIDECAR_OUT_DIR, "libsql.node")); | ||
| 250 | await cp(keyringNativePath, join(SIDECAR_OUT_DIR, "keyring.node")); | ||
| 251 | 47 | ||
| 252 | console.log(`[build-sidecar] staging web UI → ${WEB_UI_OUT_DIR}`); | 48 | if (process.platform !== "win32") { |
| 253 | await cp(APPS_LOCAL_DIST, WEB_UI_OUT_DIR, { recursive: true }); | 49 | await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755); |
| 254 | } finally { | ||
| 255 | await writeFile(EMBEDDED_MIGRATIONS_PATH, EMBEDDED_MIGRATIONS_STUB); | ||
| 256 | } | 50 | } |
| 257 | 51 | ||
| 258 | console.log("[build-sidecar] done"); | 52 | console.log(`[build-sidecar] staged bundled executor → ${EXECUTOR_OUT_DIR}`); |
| @@ -1,14 +1,14 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * End-to-end smoke test for the compiled sidecar binary. | 2 | * End-to-end smoke test for the bundled executor binary. |
| 3 | * | 3 | * |
| 4 | * Catches "works in dev, breaks in --compile" regressions: bunfs asset | 4 | * Catches "works in dev, breaks in --compile" regressions: bunfs asset |
| 5 | * loading (QuickJS WASM, staged web UI), native | 5 | * loading (embedded web UI, QuickJS WASM), native |
| 6 | * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. | 6 | * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. |
| 7 | * | 7 | * |
| 8 | * Flow: | 8 | * Flow: |
| 9 | * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). | 9 | * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). |
| 10 | * 2. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 | 10 | * 2. Spawn the compiled `executor daemon run --foreground --port 0` |
| 11 | * and parse the `EXECUTOR_READY:<port>` sentinel. | 11 | * and parse the ready URL. |
| 12 | * 3. Connect via MCP streamable HTTP, call the `execute` tool with code | 12 | * 3. Connect via MCP streamable HTTP, call the `execute` tool with code |
| 13 | * that registers and invokes the OpenAPI tool, assert the answer | 13 | * that registers and invokes the OpenAPI tool, assert the answer |
| 14 | * round-trips as 42. | 14 | * round-trips as 42. |
| @@ -29,8 +29,8 @@ const ROOT = resolve(import.meta.dir, ".."); · 14 unmodified lines | |||
| 29 | const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); | 29 | const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); |
| 30 | const BINARY = resolve( | 30 | const BINARY = resolve( |
| 31 | ROOT, | 31 | ROOT, |
| 32 | "resources/sidecar", | 32 | "resources/executor", |
| 33 | process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar", | 33 | process.platform === "win32" ? "executor.exe" : "executor", |
| 34 | ); | 34 | ); |
| 35 | 35 | ||
| 36 | const AUTH_TOKEN = "smoke-test-token"; | 36 | const AUTH_TOKEN = "smoke-test-token"; |
| @@ -38,7 +38,7 @@ const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`; · 1 unmodified line | |||
| 38 | const READY_TIMEOUT_MS = 30_000; | 38 | const READY_TIMEOUT_MS = 30_000; |
| 39 | 39 | ||
| 40 | // Throw instead of process.exit so main()'s finally still tears down the | 40 | // Throw instead of process.exit so main()'s finally still tears down the |
| 41 | // spawned sidecar + temp dirs — exiting here leaks a running sidecar process. | 41 | // spawned daemon + temp dirs — exiting here leaks a running process. |
| 42 | const fail = (msg: string): never => { | 42 | const fail = (msg: string): never => { |
| 43 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error | 43 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error |
| 44 | throw new Error(`[smoke-sidecar] FAIL: ${msg}`); | 44 | throw new Error(`[smoke-sidecar] FAIL: ${msg}`); |
| @@ -246,7 +246,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 201 unmodified lines | |||
| 246 | new Promise((resolveReady, rejectReady) => { | 246 | new Promise((resolveReady, rejectReady) => { |
| 247 | const deadline = setTimeout(() => { | 247 | const deadline = setTimeout(() => { |
| 248 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness reporting a build-time timeout | 248 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness reporting a build-time timeout |
| 249 | rejectReady(new Error(`sidecar did not announce ready within ${READY_TIMEOUT_MS}ms`)); | 249 | rejectReady(new Error(`daemon did not announce ready within ${READY_TIMEOUT_MS}ms`)); |
| 250 | }, READY_TIMEOUT_MS); | 250 | }, READY_TIMEOUT_MS); |
| 251 | 251 | ||
| 252 | let stdoutBuf = ""; | 252 | let stdoutBuf = ""; |
| @@ -258,7 +258,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 5 unmodified lines | |||
| 258 | while (true) { | 258 | while (true) { |
| 259 | const { value, done } = await stderrReader.read(); | 259 | const { value, done } = await stderrReader.read(); |
| 260 | if (done) return; | 260 | if (done) return; |
| 261 | process.stderr.write(`[sidecar-stderr] ${decoder.decode(value)}`); | 261 | process.stderr.write(`[executor-stderr] ${decoder.decode(value)}`); |
| 262 | } | 262 | } |
| 263 | })(); | 263 | })(); |
| 264 | 264 | ||
| @@ -268,13 +268,13 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 3 unmodified lines | |||
| 268 | if (done) { | 268 | if (done) { |
| 269 | clearTimeout(deadline); | 269 | clearTimeout(deadline); |
| 270 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness, stdout-closed surfaced as rejection | 270 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness, stdout-closed surfaced as rejection |
| 271 | rejectReady(new Error("sidecar stdout closed before ready")); | 271 | rejectReady(new Error("daemon stdout closed before ready")); |
| 272 | return; | 272 | return; |
| 273 | } | 273 | } |
| 274 | const chunk = decoder.decode(value); | 274 | const chunk = decoder.decode(value); |
| 275 | process.stdout.write(`[sidecar-stdout] ${chunk}`); | 275 | process.stdout.write(`[executor-stdout] ${chunk}`); |
| 276 | stdoutBuf += chunk; | 276 | stdoutBuf += chunk; |
| 277 | const match = /EXECUTOR_READY:(\d+)/.exec(stdoutBuf); | 277 | const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuf); |
| 278 | if (match) { | 278 | if (match) { |
| 279 | clearTimeout(deadline); | 279 | clearTimeout(deadline); |
| 280 | resolveReady(parseInt(match[1]!, 10)); | 280 | resolveReady(parseInt(match[1]!, 10)); |
| @@ -344,14 +344,23 @@ const main = async () => { · 63 unmodified lines | |||
| 344 | console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); | 344 | console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); |
| 345 | 345 | ||
| 346 | const proc = spawn({ | 346 | const proc = spawn({ |
| 347 | cmd: [BINARY], | 347 | cmd: [ |
| 348 | BINARY, | ||
| 349 | "daemon", | ||
| 350 | "run", | ||
| 351 | "--foreground", | ||
| 352 | "--port", | ||
| 353 | "0", | ||
| 354 | "--hostname", | ||
| 355 | "127.0.0.1", | ||
| 356 | "--auth-token", | ||
| 357 | AUTH_TOKEN, | ||
| 358 | ], | ||
| 348 | env: { | 359 | env: { |
| 349 | ...process.env, | 360 | ...process.env, |
| 350 | EXECUTOR_PORT: "0", | ||
| 351 | EXECUTOR_HOST: "127.0.0.1", | ||
| 352 | EXECUTOR_AUTH_TOKEN: AUTH_TOKEN, | ||
| 353 | EXECUTOR_SCOPE_DIR: scopeDir, | 361 | EXECUTOR_SCOPE_DIR: scopeDir, |
| 354 | EXECUTOR_DATA_DIR: dataDir, | 362 | EXECUTOR_DATA_DIR: dataDir, |
| 363 | EXECUTOR_CLIENT: "desktop", | ||
| 355 | XDG_DATA_HOME: xdgDir, | 364 | XDG_DATA_HOME: xdgDir, |
| 356 | }, | 365 | }, |
| 357 | stdin: "ignore", | 366 | stdin: "ignore", |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | } from "./diagnostics"; | 36 | } from "./diagnostics"; |
| 37 | import { sidecarCrashHtml } from "./crash-screen"; | 37 | import { sidecarCrashHtml } from "./crash-screen"; |
| 38 | import { | 38 | import { |
| 39 | bundledExecutorPath, | ||
| 39 | installSupervisedService, | 40 | installSupervisedService, |
| 40 | restartSupervisedService, | 41 | restartSupervisedService, |
| 41 | supervisedServiceStatus, | 42 | supervisedServiceStatus, |
| @@ -117,18 +118,66 @@ const stopConnection = async (conn: SidecarConnection): Promise<void> => { · 75 unmodified lines | |||
| 117 | await stopSidecar(conn.child); | 118 | await stopSidecar(conn.child); |
| 118 | }; | 119 | }; |
| 119 | 120 | ||
| 121 | const webUrlForConnection = (conn: SidecarConnection): string => { | ||
| 122 | const url = new URL(conn.baseUrl); | ||
| 123 | if (conn.authToken) url.searchParams.set("_token", conn.authToken); | ||
| 124 | url.searchParams.set("_executor_desktop_launch", String(process.pid)); | ||
| 125 | return url.toString(); | ||
| 126 | }; | ||
| 127 | |||
| 120 | // The supervised daemon (and the desktop sidecar) own this data dir — the same | 128 | // The supervised daemon (and the desktop sidecar) own this data dir — the same |
| 121 | // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. | 129 | // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. |
| 122 | const DESKTOP_DATA_DIR = join(homedir(), ".executor"); | 130 | const DESKTOP_DATA_DIR = join(homedir(), ".executor"); |
| 123 | 131 | ||
| 124 | const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); | 132 | const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); |
| 125 | 133 | ||
| 134 | const parseVersionParts = (version: string): readonly number[] | null => { | ||
| 135 | const core = version.trim().split(/[+-]/, 1)[0]; | ||
| 136 | if (!core) return null; | ||
| 137 | const parts = core.split(".").map((part) => Number.parseInt(part, 10)); | ||
| 138 | return parts.every((part) => Number.isInteger(part) && part >= 0) ? parts : null; | ||
| 139 | }; | ||
| 140 | |||
| 141 | const compareVersions = (left: string, right: string): number | null => { | ||
| 142 | const leftParts = parseVersionParts(left); | ||
| 143 | const rightParts = parseVersionParts(right); | ||
| 144 | if (!leftParts || !rightParts) return null; | ||
| 145 | const length = Math.max(leftParts.length, rightParts.length); | ||
| 146 | for (let index = 0; index < length; index += 1) { | ||
| 147 | const l = leftParts[index] ?? 0; | ||
| 148 | const r = rightParts[index] ?? 0; | ||
| 149 | if (l !== r) return l > r ? 1 : -1; | ||
| 150 | } | ||
| 151 | return 0; | ||
| 152 | }; | ||
| 153 | |||
| 154 | const shouldUpgradeDaemonForDesktop = (daemonVersion: string | null): boolean => { | ||
| 155 | if (!daemonVersion) return false; | ||
| 156 | const comparison = compareVersions(app.getVersion(), daemonVersion); | ||
| 157 | return comparison !== null && comparison > 0; | ||
| 158 | }; | ||
| 159 | |||
| 160 | const normalizedPath = (path: string): string => path.replaceAll("\\", "/"); | ||
| 161 | |||
| 162 | const shouldReplaceDaemonForDesktop = (conn: SidecarConnection): boolean => { | ||
| 163 | if (shouldUpgradeDaemonForDesktop(conn.ownerVersion)) return true; | ||
| 164 | if (!app.isPackaged) return false; | ||
| 165 | const ownerPath = conn.ownerExecutablePath; | ||
| 166 | if (!ownerPath) return false; | ||
| 167 | if (!existsSync(ownerPath)) return true; | ||
| 168 | if (conn.ownerClient !== "desktop") return false; | ||
| 169 | return normalizedPath(ownerPath) !== normalizedPath(bundledExecutorPath()); | ||
| 170 | }; | ||
| 171 | |||
| 126 | /** Poll for a reachable supervised daemon until the deadline. */ | 172 | /** Poll for a reachable supervised daemon until the deadline. */ |
| 127 | const waitForSupervisedAttach = async (timeoutMs: number): Promise<SidecarConnection | null> => { | 173 | const waitForSupervisedAttach = async ( |
| 174 | timeoutMs: number, | ||
| 175 | options: { readonly port?: number } = {}, | ||
| 176 | ): Promise<SidecarConnection | null> => { | ||
| 128 | const deadline = Date.now() + timeoutMs; | 177 | const deadline = Date.now() + timeoutMs; |
| 129 | for (;;) { | 178 | for (;;) { |
| 130 | const attached = await attachToSupervisedDaemon(); | 179 | const attached = await attachToSupervisedDaemon(); |
| 131 | if (attached) return attached; | 180 | if (attached && (options.port === undefined || attached.port === options.port)) return attached; |
| 132 | if (Date.now() >= deadline) return null; | 181 | if (Date.now() >= deadline) return null; |
| 133 | await delay(300); | 182 | await delay(300); |
| 134 | } | 183 | } |
| @@ -140,7 +189,7 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 5 unmodified lines | |||
| 140 | title: "Keep Executor running in the background?", | 189 | title: "Keep Executor running in the background?", |
| 141 | message: "Keep your connections available after you quit Executor?", | 190 | message: "Keep your connections available after you quit Executor?", |
| 142 | detail: | 191 | detail: |
| 143 | "Executor can run as a lightweight background service so your MCP tools keep working after you close this window or restart your Mac. You can turn this off anytime in Settings. It will appear under System Settings → General → Login Items.", | 192 | "Executor can run as a lightweight background service so your MCP tools keep working after you close this window or restart your computer. You can turn this off anytime in Settings.", |
| 144 | buttons: ["Keep running in the background", "Not now"], | 193 | buttons: ["Keep running in the background", "Not now"], |
| 145 | defaultId: 0, | 194 | defaultId: 0, |
| 146 | cancelId: 1, | 195 | cancelId: 1, |
| @@ -156,7 +205,21 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 9 unmodified lines | |||
| 156 | const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> => { | 205 | const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> => { |
| 157 | // 1. Already running → attach. | 206 | // 1. Already running → attach. |
| 158 | const attached = await attachToSupervisedDaemon(); | 207 | const attached = await attachToSupervisedDaemon(); |
| 159 | if (attached) return attached; | 208 | if (attached) { |
| 209 | if (!shouldReplaceDaemonForDesktop(attached)) return attached; | ||
| 210 | const settings = getServerSettings(); | ||
| 211 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: desktop launch should attach to the running daemon if automatic upgrade fails | ||
| 212 | try { | ||
| 213 | await installSupervisedService({ | ||
| 214 | port: settings.port, | ||
| 215 | dataDir: DESKTOP_DATA_DIR, | ||
| 216 | }); | ||
| 217 | return (await waitForSupervisedAttach(30_000, { port: settings.port })) ?? attached; | ||
| 218 | } catch (error) { | ||
| 219 | log.warn("Failed to replace older supervised daemon; attaching to the running daemon", error); | ||
| 220 | return attached; | ||
| 221 | } | ||
| 222 | } | ||
| 160 | 223 | ||
| 161 | const status = await supervisedServiceStatus(); | 224 | const status = await supervisedServiceStatus(); |
| 162 | if (!status.supported) return null; | 225 | if (!status.supported) return null; |
| @@ -189,9 +252,9 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 26 unmodified lines | |||
| 189 | return waitForSupervisedAttach(15_000); | 252 | return waitForSupervisedAttach(15_000); |
| 190 | }; | 253 | }; |
| 191 | 254 | ||
| 192 | // Crash monitor for the supervised daemon: launchd restarts it on crash, but | 255 | // Crash monitor for the supervised daemon: the OS service manager restarts it |
| 193 | // during that window the window's requests fail. Poll, show a reconnecting | 256 | // on crash, but during that window the window's requests fail. Poll, show a |
| 194 | // overlay while it's down, and reload once it's back. | 257 | // reconnecting overlay while it's down, and reload once it's back. |
| 195 | let supervisedMonitorTimer: ReturnType<typeof setInterval> | null = null; | 258 | let supervisedMonitorTimer: ReturnType<typeof setInterval> | null = null; |
| 196 | let supervisedDaemonDown = false; | 259 | let supervisedDaemonDown = false; |
| 197 | 260 | ||
| @@ -219,7 +282,7 @@ const armSupervisedMonitor = () => { · 21 unmodified lines | |||
| 219 | supervisedDaemonDown = false; | 282 | supervisedDaemonDown = false; |
| 220 | connection = live; | 283 | connection = live; |
| 221 | installBearerAuthHeader(live.baseUrl, live.authToken); | 284 | installBearerAuthHeader(live.baseUrl, live.authToken); |
| 222 | if (window) void window.loadURL(live.baseUrl); | 285 | if (window) void window.loadURL(webUrlForConnection(live)); |
| 223 | } | 286 | } |
| 224 | })(); | 287 | })(); |
| 225 | }, 10_000); | 288 | }, 10_000); |
| @@ -380,7 +443,7 @@ const createWindow = async (conn: SidecarConnection) => { · 154 unmodified lines | |||
| 380 | return { action: "deny" }; | 443 | return { action: "deny" }; |
| 381 | }); | 444 | }); |
| 382 | 445 | ||
| 383 | await window.loadURL(conn.baseUrl); | 446 | await window.loadURL(webUrlForConnection(conn)); |
| 384 | }; | 447 | }; |
| 385 | 448 | ||
| 386 | const showPortInUseDialog = async (port: number) => { | 449 | const showPortInUseDialog = async (port: number) => { |
| @@ -418,11 +481,12 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 31 unmodified lines | |||
| 418 | // A supervised daemon owns its own process lifetime. Re-installing the unit | 481 | // A supervised daemon owns its own process lifetime. Re-installing the unit |
| 419 | // rewrites settings such as the configured port, then launchd restarts it. | 482 | // rewrites settings such as the configured port, then launchd restarts it. |
| 420 | if (connection?.supervisedDaemon) { | 483 | if (connection?.supervisedDaemon) { |
| 484 | const port = getServerSettings().port; | ||
| 421 | await installSupervisedService({ | 485 | await installSupervisedService({ |
| 422 | port: getServerSettings().port, | 486 | port, |
| 423 | dataDir: DESKTOP_DATA_DIR, | 487 | dataDir: DESKTOP_DATA_DIR, |
| 424 | }); | 488 | }); |
| 425 | const next = await waitForSupervisedAttach(30_000); | 489 | const next = await waitForSupervisedAttach(30_000, { port }); |
| 426 | if (!next) { | 490 | if (!next) { |
| 427 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call | 491 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call |
| 428 | throw new Error("Supervised daemon failed to restart — see Settings"); | 492 | throw new Error("Supervised daemon failed to restart — see Settings"); |
| @@ -430,7 +494,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 1 unmodified line | |||
| 430 | connection = next; | 494 | connection = next; |
| 431 | installBearerAuthHeader(next.baseUrl, next.authToken); | 495 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 432 | const window = liveMainWindow(); | 496 | const window = liveMainWindow(); |
| 433 | if (window) await window.loadURL(next.baseUrl); | 497 | if (window) await window.loadURL(webUrlForConnection(next)); |
| 434 | return toDesktopServerConnection(next); | 498 | return toDesktopServerConnection(next); |
| 435 | } | 499 | } |
| 436 | if (connection) { | 500 | if (connection) { |
| @@ -445,7 +509,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 8 unmodified lines | |||
| 445 | connection = next; | 509 | connection = next; |
| 446 | installBearerAuthHeader(next.baseUrl, next.authToken); | 510 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 447 | const window = liveMainWindow(); | 511 | const window = liveMainWindow(); |
| 448 | if (window) await window.loadURL(next.baseUrl); | 512 | if (window) await window.loadURL(webUrlForConnection(next)); |
| 449 | return toDesktopServerConnection(next); | 513 | return toDesktopServerConnection(next); |
| 450 | }; | 514 | }; |
| 451 | 515 | ||
| @@ -457,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec · 5 unmodified lines | |||
| 457 | key: "desktop-sidecar", | 521 | key: "desktop-sidecar", |
| 458 | origin: conn.baseUrl, | 522 | origin: conn.baseUrl, |
| 459 | apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, | 523 | apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, |
| 460 | displayName: "Desktop sidecar", | 524 | displayName: "Local Executor", |
| 461 | }); | 525 | }); |
| 462 | 526 | ||
| 463 | const registerIpcHandlers = () => { | 527 | const registerIpcHandlers = () => { |
| @@ -486,14 +550,14 @@ const registerIpcHandlers = () => { · 22 unmodified lines | |||
| 486 | connection = active; | 550 | connection = active; |
| 487 | installBearerAuthHeader(active.baseUrl, active.authToken); | 551 | installBearerAuthHeader(active.baseUrl, active.authToken); |
| 488 | const window = liveMainWindow(); | 552 | const window = liveMainWindow(); |
| 489 | if (window) await window.loadURL(active.baseUrl); | 553 | if (window) await window.loadURL(webUrlForConnection(active)); |
| 490 | return toDesktopServerConnection(active); | 554 | return toDesktopServerConnection(active); |
| 491 | } | 555 | } |
| 492 | return restartSidecarAndReload(); | 556 | return restartSidecarAndReload(); |
| 493 | }); | 557 | }); |
| 494 | // Background-service control surface (macOS) — lets a Settings toggle enable | 558 | // Background-service control surface — lets a Settings toggle enable or |
| 495 | // or disable the supervised daemon. Disabling tears down the service and | 559 | // disable the supervised daemon. Disabling tears down the service and falls |
| 496 | // falls back to a managed sidecar on next launch. | 560 | // back to a managed sidecar on next launch. |
| 497 | ipcMain.handle("executor:service:status", () => supervisedServiceStatus()); | 561 | ipcMain.handle("executor:service:status", () => supervisedServiceStatus()); |
| 498 | ipcMain.handle( | 562 | ipcMain.handle( |
| 499 | "executor:service:set-enabled", | 563 | "executor:service:set-enabled", |
| @@ -512,7 +576,7 @@ const registerIpcHandlers = () => { · 12 unmodified lines | |||
| 512 | armSupervisedMonitor(); | 576 | armSupervisedMonitor(); |
| 513 | installBearerAuthHeader(next.baseUrl, next.authToken); | 577 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 514 | const window = liveMainWindow(); | 578 | const window = liveMainWindow(); |
| 515 | if (window) await window.loadURL(next.baseUrl); | 579 | if (window) await window.loadURL(webUrlForConnection(next)); |
| 516 | } | 580 | } |
| 517 | return true; | 581 | return true; |
| 518 | } | 582 | } |
| @@ -773,7 +837,7 @@ const boot = async () => { · 254 unmodified lines | |||
| 773 | // Prefer an OS-supervised daemon: attach to one that's running, kick one | 837 | // Prefer an OS-supervised daemon: attach to one that's running, kick one |
| 774 | // that's installed, or offer to install on first run. Quitting the app then | 838 | // that's installed, or offer to install on first run. Quitting the app then |
| 775 | // leaves MCP serving. This is also the clean handoff that replaces the old | 839 | // leaves MCP serving. This is also the clean handoff that replaces the old |
| 776 | // "another server owns the data dir → fatal error" path. Packaged macOS only; | 840 | // "another server owns the data dir → fatal error" path. Packaged builds only; |
| 777 | // dev and unsupported platforms keep managed-spawn. | 841 | // dev and unsupported platforms keep managed-spawn. |
| 778 | if (app.isPackaged) { | 842 | if (app.isPackaged) { |
| 779 | const supervised = await ensureSupervisedConnection(); | 843 | const supervised = await ensureSupervisedConnection(); |
| @@ -0,0 +1,48 @@ | |||
| 1 | /** | ||
| 2 | * Tiny desktop-main copy of the local bearer-token file contract. | ||
| 3 | * | ||
| 4 | * Keep this module free of @executor-js/local imports. The Electron main | ||
| 5 | * process only needs to mint/read/rotate auth.json so it can pass the bearer | ||
| 6 | * to the sidecar and inject it into the renderer session; importing the local | ||
| 7 | * server package here drags the whole server/native LibSQL graph into app.asar. | ||
| 8 | */ | ||
| 9 | import { randomBytes } from "node:crypto"; | ||
| 10 | import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| 11 | import { homedir } from "node:os"; | ||
| 12 | import { join, resolve } from "node:path"; | ||
| 13 | |||
| 14 | const resolveExecutorDataDir = (): string => | ||
| 15 | resolve(process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor")); | ||
| 16 | |||
| 17 | const serverControlDir = (dataDir: string): string => join(dataDir, "server-control"); | ||
| 18 | |||
| 19 | const localAuthTokenPath = (dataDir: string = resolveExecutorDataDir()): string => | ||
| 20 | join(serverControlDir(dataDir), "auth.json"); | ||
| 21 | |||
| 22 | const mintToken = (): string => randomBytes(32).toString("base64url"); | ||
| 23 | |||
| 24 | const readToken = (path: string): string | null => { | ||
| 25 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional on-disk secret may be absent or malformed | ||
| 26 | try { | ||
| 27 | // oxlint-disable-next-line executor/no-json-parse -- boundary: auth.json is a tiny local boot secret outside the Effect graph | ||
| 28 | const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown }; | ||
| 29 | return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null; | ||
| 30 | } catch { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | }; | ||
| 34 | |||
| 35 | const writeToken = (dataDir: string, token: string): string => { | ||
| 36 | const dir = serverControlDir(dataDir); | ||
| 37 | mkdirSync(dir, { recursive: true }); | ||
| 38 | const path = join(dir, "auth.json"); | ||
| 39 | writeFileSync(path, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 }); | ||
| 40 | chmodSync(path, 0o600); | ||
| 41 | return token; | ||
| 42 | }; | ||
| 43 | |||
| 44 | export const loadOrMintLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => | ||
| 45 | readToken(localAuthTokenPath(dataDir)) ?? writeToken(dataDir, mintToken()); | ||
| 46 | |||
| 47 | export const rotateLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => | ||
| 48 | writeToken(dataDir, mintToken()); | ||
| @@ -1,25 +1,11 @@ | |||
| 1 | /** | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Electron main process shells out to the bundled CLI and surfaces failures to boot/settings IPC callers */ |
| 2 | * Desktop-side manager for the OS-supervised Executor daemon (macOS launchd). | ||
| 3 | * | ||
| 4 | * The desktop drives launchd directly — it writes a LaunchAgent that runs the | ||
| 5 | * bundled `executor-sidecar` binary in supervised mode (EXECUTOR_SUPERVISED=1), | ||
| 6 | * so the daemon outlives the app and restarts on login. The app is then a thin | ||
| 7 | * client that attaches to it (see sidecar.ts `attachToSupervisedDaemon`). We do | ||
| 8 | * NOT use SMAppService: its plist must be code-signed into the bundle, whereas | ||
| 9 | * this dynamic plist points at the bundle's absolute sidecar path. The unit | ||
| 10 | * carries no secret — the daemon mints/loads its bearer from auth.json. | ||
| 11 | * | ||
| 12 | * The plist skeleton mirrors apps/cli/src/service.ts `generateLaunchdPlist` | ||
| 13 | * (the CLI is the canonical copy); keep the two in sync if the format changes. | ||
| 14 | */ | ||
| 15 | |||
| 16 | import { execFile } from "node:child_process"; | 2 | import { execFile } from "node:child_process"; |
| 17 | import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | 3 | import { existsSync } from "node:fs"; |
| 18 | import { homedir, userInfo } from "node:os"; | ||
| 19 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 20 | import { promisify } from "node:util"; | 5 | import { promisify } from "node:util"; |
| 21 | import { app } from "electron"; | 6 | import { app } from "electron"; |
| 22 | import log from "electron-log/main.js"; | 7 | import log from "electron-log/main.js"; |
| 8 | import { sidecarCrashReportingEnv } from "./diagnostics"; | ||
| 23 | 9 | ||
| 24 | const serviceLog = log.scope("service"); | 10 | const serviceLog = log.scope("service"); |
| 25 | const execFileAsync = promisify(execFile); | 11 | const execFileAsync = promisify(execFile); |
| @@ -32,112 +18,29 @@ interface CommandResult { · 6 unmodified lines | |||
| 32 | readonly stderr: string; | 18 | readonly stderr: string; |
| 33 | } | 19 | } |
| 34 | 20 | ||
| 35 | const runCommand = async (cmd: string, args: string[]): Promise<CommandResult> => { | 21 | export interface SupervisedServiceStatus { |
| 36 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capture exit code rather than throw on non-zero | 22 | readonly supported: boolean; |
| 37 | try { | 23 | readonly registered: boolean; |
| 38 | const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "utf8" }); | 24 | readonly running: boolean; |
| 39 | return { code: 0, stdout, stderr }; | 25 | } |
| 40 | } catch (error) { | ||
| 41 | const err = error as { code?: number | string; stdout?: string; stderr?: string }; | ||
| 42 | if (typeof err.code === "string") { | ||
| 43 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: command could not be spawned | ||
| 44 | throw new Error(`Failed to run \`${cmd}\`: ${err.code}`); | ||
| 45 | } | ||
| 46 | return { | ||
| 47 | code: typeof err.code === "number" ? err.code : 1, | ||
| 48 | stdout: err.stdout ?? "", | ||
| 49 | stderr: err.stderr ?? "", | ||
| 50 | }; | ||
| 51 | } | ||
| 52 | }; | ||
| 53 | |||
| 54 | const currentUid = (): number => { | ||
| 55 | const getuid = (process as { getuid?: () => number }).getuid; | ||
| 56 | return typeof getuid === "function" ? getuid.call(process) : userInfo().uid; | ||
| 57 | }; | ||
| 58 | |||
| 59 | const xmlEscape = (value: string): string => | ||
| 60 | value | ||
| 61 | .replaceAll("&", "&") | ||
| 62 | .replaceAll("<", "<") | ||
| 63 | .replaceAll(">", ">") | ||
| 64 | .replaceAll('"', """) | ||
| 65 | .replaceAll("'", "'"); | ||
| 66 | |||
| 67 | const launchAgentsDir = (): string => join(homedir(), "Library", "LaunchAgents"); | ||
| 68 | const plistPath = (): string => join(launchAgentsDir(), `${SERVICE_LABEL}.plist`); | ||
| 69 | const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`; | ||
| 70 | |||
| 71 | const sidecarBinaryPath = (): string => { | ||
| 72 | const name = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; | ||
| 73 | return join(process.resourcesPath, "sidecar", name); | ||
| 74 | }; | ||
| 75 | |||
| 76 | const webUiDir = (): string => join(process.resourcesPath, "web-ui"); | ||
| 77 | 26 | ||
| 78 | interface PlistOptions { | 27 | export interface InstallOptions { |
| 79 | readonly label: string; | 28 | readonly port: number; |
| 80 | readonly programArguments: ReadonlyArray<string>; | 29 | readonly dataDir: string; |
| 81 | readonly environment: Record<string, string>; | ||
| 82 | readonly stdoutPath: string; | ||
| 83 | readonly stderrPath: string; | ||
| 84 | readonly workingDirectory: string; | ||
| 85 | } | 30 | } |
| 86 | 31 | ||
| 87 | const generateLaunchdPlist = (options: PlistOptions): string => { | 32 | export const bundledExecutorPath = (): string => |
| 88 | const programArgs = options.programArguments | 33 | join( |
| 89 | .map((arg) => ` <string>${xmlEscape(arg)}</string>`) | 34 | process.resourcesPath, |
| 90 | .join("\n"); | 35 | "executor", |
| 91 | const envEntries = Object.entries(options.environment) | 36 | process.platform === "win32" ? "executor.exe" : "executor", |
| 92 | .map( | 37 | ); |
| 93 | ([key, value]) => | 38 | |
| 94 | ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`, | 39 | const executorAvailable = (): boolean => app.isPackaged && existsSync(bundledExecutorPath()); |
| 95 | ) | ||
| 96 | .join("\n"); | ||
| 97 | return `<?xml version="1.0" encoding="UTF-8"?> | ||
| 98 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 99 | <plist version="1.0"> | ||
| 100 | <dict> | ||
| 101 | <key>Label</key> | ||
| 102 | <string>${xmlEscape(options.label)}</string> | ||
| 103 | <key>ProgramArguments</key> | ||
| 104 | <array> | ||
| 105 | ${programArgs} | ||
| 106 | </array> | ||
| 107 | <key>EnvironmentVariables</key> | ||
| 108 | <dict> | ||
| 109 | ${envEntries} | ||
| 110 | </dict> | ||
| 111 | <key>RunAtLoad</key> | ||
| 112 | <true/> | ||
| 113 | <key>KeepAlive</key> | ||
| 114 | <dict> | ||
| 115 | <key>SuccessfulExit</key> | ||
| 116 | <false/> | ||
| 117 | </dict> | ||
| 118 | <key>ProcessType</key> | ||
| 119 | <string>Background</string> | ||
| 120 | <key>WorkingDirectory</key> | ||
| 121 | <string>${xmlEscape(options.workingDirectory)}</string> | ||
| 122 | <key>StandardOutPath</key> | ||
| 123 | <string>${xmlEscape(options.stdoutPath)}</string> | ||
| 124 | <key>StandardErrorPath</key> | ||
| 125 | <string>${xmlEscape(options.stderrPath)}</string> | ||
| 126 | </dict> | ||
| 127 | </plist> | ||
| 128 | `; | ||
| 129 | }; | ||
| 130 | 40 | ||
| 131 | /** | ||
| 132 | * Capture the user's login-shell PATH. A launchd daemon starts with a bare | ||
| 133 | * PATH; without the user's PATH the daemon can't find pyenv/nvm/Homebrew tools | ||
| 134 | * that integrations may shell out to. Falls back to the app's own PATH. | ||
| 135 | * (Reference: opencode's shell-env capture.) | ||
| 136 | */ | ||
| 137 | const captureUserPath = async (): Promise<string | undefined> => { | 41 | const captureUserPath = async (): Promise<string | undefined> => { |
| 138 | const shell = process.env.SHELL; | 42 | const shell = process.env.SHELL; |
| 139 | if (!shell) return process.env.PATH; | 43 | if (!shell) return process.env.PATH; |
| 140 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a slow/odd login shell must not break install | ||
| 141 | try { | 44 | try { |
| 142 | const { stdout } = await execFileAsync(shell, ["-ilc", 'printf "%s" "$PATH"'], { | 45 | const { stdout } = await execFileAsync(shell, ["-ilc", 'printf "%s" "$PATH"'], { |
| 143 | encoding: "utf8", | 46 | encoding: "utf8", |
| @@ -150,87 +53,91 @@ const captureUserPath = async (): Promise<string | undefined> => { · 6 unmodified lines | |||
| 150 | } | 53 | } |
| 151 | }; | 54 | }; |
| 152 | 55 | ||
| 153 | export interface SupervisedServiceStatus { | 56 | const serviceEnv = async (dataDir: string): Promise<NodeJS.ProcessEnv> => ({ |
| 154 | readonly supported: boolean; | 57 | ...process.env, |
| 155 | readonly registered: boolean; | 58 | ...(await captureUserPath().then((path) => (path ? { PATH: path } : {}))), |
| 156 | readonly running: boolean; | 59 | EXECUTOR_DATA_DIR: dataDir, |
| 157 | } | 60 | EXECUTOR_SCOPE_DIR: dataDir, |
| 61 | EXECUTOR_CLIENT: "desktop", | ||
| 62 | ...sidecarCrashReportingEnv(), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const runExecutor = async ( | ||
| 66 | args: ReadonlyArray<string>, | ||
| 67 | options: { readonly dataDir: string }, | ||
| 68 | ): Promise<CommandResult> => { | ||
| 69 | const bin = bundledExecutorPath(); | ||
| 70 | try { | ||
| 71 | const { stdout, stderr } = await execFileAsync(bin, [...args], { | ||
| 72 | encoding: "utf8", | ||
| 73 | env: await serviceEnv(options.dataDir), | ||
| 74 | }); | ||
| 75 | return { code: 0, stdout, stderr }; | ||
| 76 | } catch (error) { | ||
| 77 | const err = error as { code?: number | string; stdout?: string; stderr?: string }; | ||
| 78 | if (typeof err.code === "string") { | ||
| 79 | throw new Error(`Failed to run \`${bin}\`: ${err.code}`); | ||
| 80 | } | ||
| 81 | return { | ||
| 82 | code: typeof err.code === "number" ? err.code : 1, | ||
| 83 | stdout: err.stdout ?? "", | ||
| 84 | stderr: err.stderr ?? "", | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | }; | ||
| 158 | 88 | ||
| 159 | const isSupported = (): boolean => app.isPackaged && process.platform === "darwin"; | 89 | const statusValue = (stdout: string, key: "Registered" | "Running"): boolean => |
| 90 | new RegExp(`^${key}:\\s+yes(?:\\s|$)`, "im").test(stdout); | ||
| 160 | 91 | ||
| 161 | export const supervisedServiceStatus = async (): Promise<SupervisedServiceStatus> => { | 92 | export const supervisedServiceStatus = async (): Promise<SupervisedServiceStatus> => { |
| 162 | if (!isSupported()) return { supported: false, registered: false, running: false }; | 93 | if (!executorAvailable()) return { supported: false, registered: false, running: false }; |
| 163 | const registered = existsSync(plistPath()); | 94 | const dataDir = join(app.getPath("home"), ".executor"); |
| 164 | const print = await runCommand("launchctl", ["print", serviceTarget(currentUid())]); | 95 | const result = await runExecutor(["service", "status"], { dataDir }); |
| 165 | return { supported: true, registered, running: print.code === 0 }; | 96 | if (result.code !== 0) { |
| 97 | serviceLog.warn(`service status failed: ${result.stderr || result.stdout}`); | ||
| 98 | return { supported: true, registered: false, running: false }; | ||
| 99 | } | ||
| 100 | const supported = !/^Platform:\s+unsupported$/im.test(result.stdout); | ||
| 101 | return { | ||
| 102 | supported, | ||
| 103 | registered: supported && statusValue(result.stdout, "Registered"), | ||
| 104 | running: supported && statusValue(result.stdout, "Running"), | ||
| 105 | }; | ||
| 166 | }; | 106 | }; |
| 167 | 107 | ||
| 168 | export interface InstallOptions { | ||
| 169 | readonly port: number; | ||
| 170 | readonly dataDir: string; | ||
| 171 | } | ||
| 172 | |||
| 173 | /** | ||
| 174 | * Register + start the supervised daemon (the bundled sidecar under launchd). | ||
| 175 | * The unit carries no secret — the daemon mints/loads its bearer from auth.json | ||
| 176 | * under EXECUTOR_DATA_DIR, and desktop/CLI clients read the same file. | ||
| 177 | */ | ||
| 178 | export const installSupervisedService = async (opts: InstallOptions): Promise<void> => { | 108 | export const installSupervisedService = async (opts: InstallOptions): Promise<void> => { |
| 179 | const uid = currentUid(); | 109 | if (!executorAvailable()) { |
| 180 | const logs = join(opts.dataDir, "logs"); | 110 | throw new Error("Bundled executor binary is not available."); |
| 181 | mkdirSync(launchAgentsDir(), { recursive: true }); | 111 | } |
| 182 | mkdirSync(logs, { recursive: true }); | 112 | const result = await runExecutor(["install", "--port", String(opts.port)], { |
| 183 | 113 | dataDir: opts.dataDir, | |
| 184 | const userPath = await captureUserPath(); | ||
| 185 | const environment: Record<string, string> = { | ||
| 186 | EXECUTOR_SUPERVISED: "1", | ||
| 187 | EXECUTOR_PORT: String(opts.port), | ||
| 188 | EXECUTOR_HOST: "127.0.0.1", | ||
| 189 | EXECUTOR_DATA_DIR: opts.dataDir, | ||
| 190 | EXECUTOR_SCOPE_DIR: opts.dataDir, | ||
| 191 | EXECUTOR_CLIENT_DIR: webUiDir(), | ||
| 192 | EXECUTOR_CLIENT: "desktop", | ||
| 193 | EXECUTOR_SERVICE_VERSION: app.getVersion() || "", | ||
| 194 | ...(userPath ? { PATH: userPath } : {}), | ||
| 195 | }; | ||
| 196 | |||
| 197 | const plist = generateLaunchdPlist({ | ||
| 198 | label: SERVICE_LABEL, | ||
| 199 | programArguments: [sidecarBinaryPath()], | ||
| 200 | environment, | ||
| 201 | stdoutPath: join(logs, "daemon.log"), | ||
| 202 | stderrPath: join(logs, "daemon.error.log"), | ||
| 203 | workingDirectory: opts.dataDir, | ||
| 204 | }); | 114 | }); |
| 205 | writeFileSync(plistPath(), plist, { mode: 0o600 }); | 115 | if (result.code !== 0) { |
| 206 | 116 | throw new Error((result.stderr || result.stdout).trim() || "`executor install` failed."); | |
| 207 | // Re-bootstrap cleanly so a stale registration doesn't make bootstrap fail. | ||
| 208 | // `uninstallSupervisedService` also records the label as disabled in | ||
| 209 | // launchd's override database; clear that before bootstrapping or reinstall | ||
| 210 | // can fail with launchctl's generic "Bootstrap failed: 5" error. | ||
| 211 | await runCommand("launchctl", ["bootout", serviceTarget(uid)]); | ||
| 212 | await runCommand("launchctl", ["enable", serviceTarget(uid)]); | ||
| 213 | const bootstrap = await runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath()]); | ||
| 214 | if (bootstrap.code !== 0) { | ||
| 215 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to the boot flow | ||
| 216 | throw new Error( | ||
| 217 | `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`, | ||
| 218 | ); | ||
| 219 | } | 117 | } |
| 220 | serviceLog.info(`installed supervised service on port ${opts.port}`); | 118 | serviceLog.info(`installed supervised service via bundled executor on port ${opts.port}`); |
| 221 | }; | 119 | }; |
| 222 | 120 | ||
| 223 | export const uninstallSupervisedService = async (dataDir: string): Promise<void> => { | 121 | export const uninstallSupervisedService = async (dataDir: string): Promise<void> => { |
| 224 | const uid = currentUid(); | 122 | if (!executorAvailable()) return; |
| 225 | await runCommand("launchctl", ["bootout", serviceTarget(uid)]); | 123 | const result = await runExecutor(["service", "uninstall"], { dataDir }); |
| 226 | await runCommand("launchctl", ["disable", serviceTarget(uid)]); | 124 | if (result.code !== 0) { |
| 227 | rmSync(plistPath(), { force: true }); | 125 | throw new Error( |
| 228 | // Clean up a legacy service.key from a pre-bearer install (best-effort). | 126 | (result.stderr || result.stdout).trim() || "`executor service uninstall` failed.", |
| 229 | rmSync(join(dataDir, "server-control", "service.key"), { force: true }); | 127 | ); |
| 230 | serviceLog.info("uninstalled supervised service"); | 128 | } |
| 129 | serviceLog.info("uninstalled supervised service via bundled executor"); | ||
| 231 | }; | 130 | }; |
| 232 | 131 | ||
| 233 | /** Restart the supervised daemon atomically (kill + relaunch via launchd). */ | ||
| 234 | export const restartSupervisedService = async (): Promise<void> => { | 132 | export const restartSupervisedService = async (): Promise<void> => { |
| 235 | await runCommand("launchctl", ["kickstart", "-k", serviceTarget(currentUid())]); | 133 | if (!executorAvailable()) { |
| 134 | throw new Error("Bundled executor binary is not available."); | ||
| 135 | } | ||
| 136 | const dataDir = join(app.getPath("home"), ".executor"); | ||
| 137 | const result = await runExecutor(["service", "restart"], { dataDir }); | ||
| 138 | if (result.code !== 0) { | ||
| 139 | throw new Error( | ||
| 140 | (result.stderr || result.stdout).trim() || "`executor service restart` failed.", | ||
| 141 | ); | ||
| 142 | } | ||
| 236 | }; | 143 | }; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import Store from "electron-store"; | 1 | import Store from "electron-store"; |
| 2 | import { rotateLocalAuthToken } from "@executor-js/local/auth"; | 2 | import { rotateLocalAuthToken } from "./local-auth"; |
| 3 | import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; | 3 | import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; |
| 4 | 4 | ||
| 5 | interface PersistedShape { | 5 | interface PersistedShape { |
| @@ -9,6 +9,9 @@ interface PersistedShape { · 3 unmodified lines | |||
| 9 | 9 | ||
| 10 | const store = new Store<PersistedShape>({ | 10 | const store = new Store<PersistedShape>({ |
| 11 | name: "settings", | 11 | name: "settings", |
| 12 | ...(process.env.EXECUTOR_DESKTOP_SETTINGS_DIR | ||
| 13 | ? { cwd: process.env.EXECUTOR_DESKTOP_SETTINGS_DIR } | ||
| 14 | : {}), | ||
| 12 | defaults: { server: DEFAULT_SERVER_SETTINGS }, | 15 | defaults: { server: DEFAULT_SERVER_SETTINGS }, |
| 13 | }); | 16 | }); |
| 14 | 17 | ||
| @@ -2,13 +2,11 @@ · 1 unmodified line | |||
| 2 | * Sidecar lifecycle manager run inside the Electron main process. | 2 | * Sidecar lifecycle manager run inside the Electron main process. |
| 3 | * | 3 | * |
| 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 Bun-compiled `executor-sidecar` binary shipped under | 5 | * In prod: spawns the bundled CLI binary in foreground daemon mode. |
| 6 | * `process.resourcesPath/sidecar/`. | ||
| 7 | * | 6 | * |
| 8 | * 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 |
| 9 | * via env, calls `startServer()` from `@executor-js/local`, and announces a | 8 | * The dev sidecar announces `EXECUTOR_READY:<port>`. The packaged CLI daemon |
| 10 | * single sentinel line on stdout (`EXECUTOR_READY:<port>`) so this controller | 9 | * announces `Daemon ready on http://host:port`. This controller accepts both. |
| 11 | * can resolve the connection promise. | ||
| 12 | */ | 10 | */ |
| 13 | 11 | ||
| 14 | import { spawn, type ChildProcess } from "node:child_process"; | 12 | import { spawn, type ChildProcess } from "node:child_process"; |
| @@ -23,7 +21,7 @@ import { · 8 unmodified lines | |||
| 23 | parseExecutorLocalServerManifest, | 21 | parseExecutorLocalServerManifest, |
| 24 | serializeExecutorLocalServerManifest, | 22 | serializeExecutorLocalServerManifest, |
| 25 | } from "@executor-js/sdk/shared"; | 23 | } from "@executor-js/sdk/shared"; |
| 26 | import { loadOrMintLocalAuthToken } from "@executor-js/local/auth"; | 24 | import { loadOrMintLocalAuthToken } from "./local-auth"; |
| 27 | import { getServerSettings } from "./settings"; | 25 | import { getServerSettings } from "./settings"; |
| 28 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; | 26 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; |
| 29 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; | 27 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; |
| @@ -79,6 +77,9 @@ export interface SidecarConnection { · 49 unmodified lines | |||
| 79 | * leave MCP serving. | 77 | * leave MCP serving. |
| 80 | */ | 78 | */ |
| 81 | readonly supervisedDaemon: boolean; | 79 | readonly supervisedDaemon: boolean; |
| 80 | readonly ownerVersion: string | null; | ||
| 81 | readonly ownerClient: "cli" | "desktop"; | ||
| 82 | readonly ownerExecutablePath: string | null; | ||
| 82 | } | 83 | } |
| 83 | 84 | ||
| 84 | export class SidecarPortInUseError extends Error { | 85 | export class SidecarPortInUseError extends Error { |
| @@ -217,40 +218,48 @@ const writeSidecarManifest = (input: { · 132 unmodified lines | |||
| 217 | sidecarManifestPathByPid.set(input.childPid, input.dataDir); | 218 | sidecarManifestPathByPid.set(input.childPid, input.dataDir); |
| 218 | }; | 219 | }; |
| 219 | 220 | ||
| 220 | const resolveSidecarCommand = (): { command: string; args: string[]; cwd: string } => { | 221 | const resolveSidecarCommand = (input: { |
| 222 | readonly port: number; | ||
| 223 | readonly hostname: string; | ||
| 224 | readonly authToken: string; | ||
| 225 | }): { command: string; args: string[]; cwd: string; cliManagedManifest: boolean } => { | ||
| 221 | if (app.isPackaged) { | 226 | if (app.isPackaged) { |
| 222 | const binaryName = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; | 227 | const binaryName = process.platform === "win32" ? "executor.exe" : "executor"; |
| 223 | const binaryPath = join(process.resourcesPath, "sidecar", binaryName); | 228 | const binaryPath = join(process.resourcesPath, "executor", binaryName); |
| 224 | return { command: binaryPath, args: [], cwd: process.resourcesPath }; | 229 | return { |
| 230 | command: binaryPath, | ||
| 231 | args: [ | ||
| 232 | "daemon", | ||
| 233 | "run", | ||
| 234 | "--foreground", | ||
| 235 | "--port", | ||
| 236 | String(input.port), | ||
| 237 | "--hostname", | ||
| 238 | input.hostname, | ||
| 239 | "--auth-token", | ||
| 240 | input.authToken, | ||
| 241 | ], | ||
| 242 | cwd: process.resourcesPath, | ||
| 243 | cliManagedManifest: true, | ||
| 244 | }; | ||
| 225 | } | 245 | } |
| 226 | // Dev: run the TS source directly via bun on PATH. | 246 | // Dev: run the TS source directly via bun on PATH. |
| 227 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); | 247 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); |
| 228 | const sidecarSource = resolve(repoRoot, "apps/desktop/src/sidecar/server.ts"); | 248 | const sidecarSource = resolve(repoRoot, "apps/desktop/src/sidecar/server.ts"); |
| 229 | return { command: "bun", args: ["run", sidecarSource], cwd: repoRoot }; | 249 | return { command: "bun", args: ["run", sidecarSource], cwd: repoRoot, cliManagedManifest: false }; |
| 230 | }; | 250 | }; |
| 231 | 251 | ||
| 232 | const resolveClientDir = (): string => { | 252 | const resolveClientDir = (): string => { |
| 233 | if (app.isPackaged) { | ||
| 234 | return join(process.resourcesPath, "web-ui"); | ||
| 235 | } | ||
| 236 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); | 253 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); |
| 237 | return resolve(repoRoot, "apps/local/dist"); | 254 | return resolve(repoRoot, "apps/local/dist"); |
| 238 | }; | 255 | }; |
| 239 | 256 | ||
| 257 | const delay = (ms: number): Promise<void> => | ||
| 258 | new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); | ||
| 259 | |||
| 240 | export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { | 260 | export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { |
| 241 | const hostname = options.hostname ?? "127.0.0.1"; | 261 | const hostname = options.hostname ?? "127.0.0.1"; |
| 242 | const settings = getServerSettings(); | 262 | const settings = getServerSettings(); |
| 243 | const clientDir = resolveClientDir(); | ||
| 244 | const { command, args, cwd } = resolveSidecarCommand(); | ||
| 245 | |||
| 246 | if (!existsSync(clientDir)) { | ||
| 247 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: misconfiguration is fatal | ||
| 248 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process | ||
| 249 | throw new Error( | ||
| 250 | `Executor client bundle not found at ${clientDir}. Run \`bun run --filter @executor-js/local build\` before launching desktop.`, | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | // data.db and the optional executor.jsonc plugin manifest live under | 263 | // data.db and the optional executor.jsonc plugin manifest live under |
| 255 | // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI | 264 | // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI |
| 256 | // share state on the same machine so sources/secrets/policies set up in one | 265 | // share state on the same machine so sources/secrets/policies set up in one |
| @@ -267,21 +276,37 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 10 unmodified lines | |||
| 267 | // process holds it so it can inject the header into the webview; the child | 276 | // process holds it so it can inject the header into the webview; the child |
| 268 | // validates against the same value. Always present — auth is unconditional. | 277 | // validates against the same value. Always present — auth is unconditional. |
| 269 | const authToken = loadOrMintLocalAuthToken(dataDir); | 278 | const authToken = loadOrMintLocalAuthToken(dataDir); |
| 270 | const releaseStartupLock = acquireLocalServerStartLock(dataDir); | 279 | const { command, args, cwd, cliManagedManifest } = resolveSidecarCommand({ |
| 280 | port: settings.port, | ||
| 281 | hostname, | ||
| 282 | authToken, | ||
| 283 | }); | ||
| 284 | const clientDir = cliManagedManifest ? null : resolveClientDir(); | ||
| 285 | |||
| 286 | if (!cliManagedManifest && clientDir && !existsSync(clientDir)) { | ||
| 287 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process | ||
| 288 | throw new Error( | ||
| 289 | `Executor client bundle not found at ${clientDir}. Run \`bun run --filter @executor-js/local build\` before launching desktop.`, | ||
| 290 | ); | ||
| 291 | } | ||
| 292 | |||
| 293 | const releaseStartupLock = cliManagedManifest ? null : acquireLocalServerStartLock(dataDir); | ||
| 271 | let startupLockReleased = false; | 294 | let startupLockReleased = false; |
| 272 | const releaseLock = () => { | 295 | const releaseLock = () => { |
| 273 | if (startupLockReleased) return; | 296 | if (startupLockReleased) return; |
| 274 | startupLockReleased = true; | 297 | startupLockReleased = true; |
| 275 | releaseStartupLock(); | 298 | releaseStartupLock?.(); |
| 276 | }; | 299 | }; |
| 277 | 300 | ||
| 278 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures | 301 | if (!cliManagedManifest) { |
| 279 | try { | 302 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures |
| 280 | assertNoOtherLocalServerOwner(dataDir); | 303 | try { |
| 281 | } catch (error) { | 304 | assertNoOtherLocalServerOwner(dataDir); |
| 282 | releaseLock(); | 305 | } catch (error) { |
| 283 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock | 306 | releaseLock(); |
| 284 | throw error; | 307 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock |
| 308 | throw error; | ||
| 309 | } | ||
| 285 | } | 310 | } |
| 286 | 311 | ||
| 287 | let child: ChildProcess; | 312 | let child: ChildProcess; |
| @@ -300,7 +325,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 12 unmodified lines | |||
| 300 | // The bearer token the child validates and the main process injects into | 325 | // The bearer token the child validates and the main process injects into |
| 301 | // the webview. Always set — auth is unconditional. | 326 | // the webview. Always set — auth is unconditional. |
| 302 | EXECUTOR_AUTH_TOKEN: authToken, | 327 | EXECUTOR_AUTH_TOKEN: authToken, |
| 303 | EXECUTOR_CLIENT_DIR: clientDir, | 328 | ...(clientDir ? { EXECUTOR_CLIENT_DIR: clientDir } : {}), |
| 304 | EXECUTOR_SCOPE_DIR: scopeDir, | 329 | EXECUTOR_SCOPE_DIR: scopeDir, |
| 305 | EXECUTOR_DATA_DIR: dataDir, | 330 | EXECUTOR_DATA_DIR: dataDir, |
| 306 | EXECUTOR_CLIENT: "desktop", | 331 | EXECUTOR_CLIENT: "desktop", |
| @@ -334,9 +359,11 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 27 unmodified lines | |||
| 334 | 359 | ||
| 335 | const onStdout = (chunk: Buffer) => { | 360 | const onStdout = (chunk: Buffer) => { |
| 336 | const text = chunk.toString("utf8"); | 361 | const text = chunk.toString("utf8"); |
| 337 | process.stdout.write(`[executor-sidecar] ${text}`); | 362 | process.stdout.write(`[executor-server] ${text}`); |
| 338 | logStdoutLine(text); | 363 | logStdoutLine(text); |
| 339 | const match = text.match(/EXECUTOR_READY:(\d+)/); | 364 | const match = |
| 365 | text.match(/EXECUTOR_READY:(\d+)/) ?? | ||
| 366 | text.match(/Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/); | ||
| 340 | if (match && !resolved) { | 367 | if (match && !resolved) { |
| 341 | if (!child.pid) { | 368 | if (!child.pid) { |
| 342 | reject( | 369 | reject( |
| @@ -348,13 +375,15 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines | |||
| 348 | resolved = true; | 375 | resolved = true; |
| 349 | const port = parseInt(match[1], 10); | 376 | const port = parseInt(match[1], 10); |
| 350 | const baseUrl = `http://${hostname}:${port}`; | 377 | const baseUrl = `http://${hostname}:${port}`; |
| 351 | writeSidecarManifest({ | 378 | if (!cliManagedManifest) { |
| 352 | dataDir, | 379 | writeSidecarManifest({ |
| 353 | scopeDir, | 380 | dataDir, |
| 354 | baseUrl, | 381 | scopeDir, |
| 355 | authToken, | 382 | baseUrl, |
| 356 | childPid: child.pid, | 383 | authToken, |
| 357 | }); | 384 | childPid: child.pid, |
| 385 | }); | ||
| 386 | } | ||
| 358 | releaseLock(); | 387 | releaseLock(); |
| 359 | resolveStart({ | 388 | resolveStart({ |
| 360 | baseUrl, | 389 | baseUrl, |
| @@ -364,6 +393,9 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 3 unmodified lines | |||
| 364 | authToken, | 393 | authToken, |
| 365 | child, | 394 | child, |
| 366 | supervisedDaemon: false, | 395 | supervisedDaemon: false, |
| 396 | ownerVersion: app.getVersion() || null, | ||
| 397 | ownerClient: "desktop", | ||
| 398 | ownerExecutablePath: command, | ||
| 367 | }); | 399 | }); |
| 368 | } | 400 | } |
| 369 | }; | 401 | }; |
| @@ -371,7 +403,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 1 unmodified line | |||
| 371 | const onStderr = (chunk: Buffer) => { | 403 | const onStderr = (chunk: Buffer) => { |
| 372 | const text = chunk.toString("utf8"); | 404 | const text = chunk.toString("utf8"); |
| 373 | stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT); | 405 | stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT); |
| 374 | process.stderr.write(`[executor-sidecar] ${text}`); | 406 | process.stderr.write(`[executor-server] ${text}`); |
| 375 | logStderrLine(text); | 407 | logStderrLine(text); |
| 376 | }; | 408 | }; |
| 377 | 409 | ||
| @@ -410,21 +442,25 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 32 unmodified lines | |||
| 410 | 442 | ||
| 411 | /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */ | 443 | /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */ |
| 412 | const isDaemonReachable = async (origin: string): Promise<boolean> => { | 444 | const isDaemonReachable = async (origin: string): Promise<boolean> => { |
| 413 | const controller = new AbortController(); | 445 | for (let attempt = 0; attempt < 3; attempt += 1) { |
| 414 | const timer = setTimeout(() => controller.abort(), 1500); | 446 | const controller = new AbortController(); |
| 415 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal | 447 | const timer = setTimeout(() => controller.abort(), 1500); |
| 416 | try { | 448 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal |
| 417 | const response = await fetch(new URL("/api/health", origin), { | 449 | try { |
| 418 | signal: controller.signal, | 450 | const response = await fetch(new URL("/api/health", origin), { |
| 419 | redirect: "manual", | 451 | signal: controller.signal, |
| 420 | }); | 452 | redirect: "manual", |
| 421 | const body = await response.text(); | 453 | }); |
| 422 | return response.ok && body.trim() === "ok"; | 454 | const body = await response.text(); |
| 423 | } catch { | 455 | if (response.ok && body.trim() === "ok") return true; |
| 424 | return false; | 456 | } catch { |
| 425 | } finally { | 457 | // retry below |
| 426 | clearTimeout(timer); | 458 | } finally { |
| 459 | clearTimeout(timer); | ||
| 460 | } | ||
| 461 | if (attempt < 2) await delay(150); | ||
| 427 | } | 462 | } |
| 463 | return false; | ||
| 428 | }; | 464 | }; |
| 429 | 465 | ||
| 430 | /** | 466 | /** |
| @@ -450,7 +486,11 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 19 unmodified lines | |||
| 450 | const origin = manifest.connection.origin; | 486 | const origin = manifest.connection.origin; |
| 451 | const auth = manifest.connection.auth; | 487 | const auth = manifest.connection.auth; |
| 452 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; | 488 | const authToken = auth && auth.kind === "bearer" ? auth.token : ""; |
| 453 | if (!(await isDaemonReachable(origin))) return null; | 489 | if (!(await isDaemonReachable(origin))) { |
| 490 | sidecarLog.info(`removing stale supervised daemon manifest for unreachable ${origin}`); | ||
| 491 | removeManifestIfOwnedBy(dataDir, manifest.pid); | ||
| 492 | return null; | ||
| 493 | } | ||
| 454 | 494 | ||
| 455 | const url = new URL(origin); | 495 | const url = new URL(origin); |
| 456 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); | 496 | sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); |
| @@ -462,6 +502,9 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 5 unmodified lines | |||
| 462 | authToken, | 502 | authToken, |
| 463 | child: null, | 503 | child: null, |
| 464 | supervisedDaemon: true, | 504 | supervisedDaemon: true, |
| 505 | ownerVersion: manifest.owner.version, | ||
| 506 | ownerClient: manifest.owner.client, | ||
| 507 | ownerExecutablePath: manifest.owner.executablePath, | ||
| 465 | }; | 508 | }; |
| 466 | } | 509 | } |
| 467 | 510 | ||
| @@ -6,7 +6,7 @@ · 5 unmodified lines | |||
| 6 | * renderer (Settings UI + Connect-an-agent surface) need to agree on it. | 6 | * renderer (Settings UI + Connect-an-agent surface) need to agree on it. |
| 7 | * | 7 | * |
| 8 | * Auth is NOT a setting: the sidecar always enforces the locally-minted bearer | 8 | * Auth is NOT a setting: the sidecar always enforces the locally-minted bearer |
| 9 | * token (see `@executor-js/local` auth.json). The main process injects it into | 9 | * token (see `~/.executor/server-control/auth.json`). The main process injects it into |
| 10 | * the webview transparently, so the renderer never sees the credential and | 10 | * the webview transparently, so the renderer never sees the credential and |
| 11 | * there is nothing to toggle or persist here. | 11 | * there is nothing to toggle or persist here. |
| 12 | */ | 12 | */ |
| @@ -1,10 +1,11 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Native-binding bootstrap for the `bun build --compile` sidecar binary. | 2 | // Native-binding bootstrap for manually compiled sidecar binaries. |
| 3 | // | 3 | // |
| 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native | 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native |
| 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside |
| 6 | // the binary fails. build-sidecar.ts copies each platform's `.node` next to | 6 | // the binary fails. If this dev sidecar is compiled directly, copy each |
| 7 | // the executable (`libsql.node`, `keyring.node`); here we publish their | 7 | // platform's `.node` next to the executable (`libsql.node`, `keyring.node`); |
| 8 | // here we publish their | ||
| 8 | // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. | 9 | // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. |
| 9 | // | 10 | // |
| 10 | // This MUST be the FIRST import in server.ts. ES modules evaluate every import | 11 | // This MUST be the FIRST import in server.ts. ES modules evaluate every import |
| @@ -1,7 +1,7 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * Bun-side sidecar entry. Spawned by the Electron main process as a child | 2 | * Bun-side sidecar entry. Spawned by the Electron main process in dev via |
| 3 | * process (either via `bun run ...` in dev or as a Bun-compiled binary in | 3 | * `bun run ...`. Packaged desktop uses the bundled `executor` CLI binary |
| 4 | * production). | 4 | * instead. |
| 5 | * | 5 | * |
| 6 | * Reads connection parameters from env, boots the executor server, then | 6 | * Reads connection parameters from env, boots the executor server, then |
| 7 | * announces readiness with the resolved port on stdout so the Electron | 7 | * announces readiness with the resolved port on stdout so the Electron |
| @@ -12,11 +12,9 @@ · 4 unmodified lines | |||
| 12 | import "./native-bindings"; | 12 | import "./native-bindings"; |
| 13 | import { dirname, join } from "node:path"; | 13 | import { dirname, join } from "node:path"; |
| 14 | 14 | ||
| 15 | // Pre-load QuickJS WASM for compiled binaries. `bun build --compile` can't | 15 | // Pre-load QuickJS WASM for manually compiled sidecar binaries. Packaged |
| 16 | // embed the side-asset WASM that `quickjs-emscripten` ships with, so | 16 | // desktop no longer ships this entrypoint, but keeping the preload here lets |
| 17 | // build-sidecar.ts stages it next to this binary and we feed the bytes in | 17 | // direct sidecar smoke/debug runs behave like the CLI binary. |
| 18 | // via `setQuickJSModule` before any server import touches QuickJS. Mirrors | ||
| 19 | // the CLI's preload in apps/cli/src/main.ts. | ||
| 20 | const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm"); | 18 | const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm"); |
| 21 | if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { | 19 | if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { |
| 22 | const { setQuickJSModule } = await import("@executor-js/runtime-quickjs"); | 20 | const { setQuickJSModule } = await import("@executor-js/runtime-quickjs"); |
| @@ -30,6 +30,11 @@ import { · 29 unmodified lines | |||
| 30 | 30 | ||
| 31 | type StaticHandler = () => Response | Promise<Response>; | 31 | type StaticHandler = () => Response | Promise<Response>; |
| 32 | 32 | ||
| 33 | const htmlResponse = (file: Bun.BunFile): Response => | ||
| 34 | new Response(file, { | ||
| 35 | headers: { "content-type": "text/html", "cache-control": "no-store" }, | ||
| 36 | }); | ||
| 37 | |||
| 33 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { | 38 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { |
| 34 | const routes: Record<string, StaticHandler> = {}; | 39 | const routes: Record<string, StaticHandler> = {}; |
| 35 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets | 40 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets |
| @@ -42,9 +47,11 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 6 unmodified lines | |||
| 42 | } else { | 47 | } else { |
| 43 | const file = Bun.file(fullPath); | 48 | const file = Bun.file(fullPath); |
| 44 | routes[routePath] = () => | 49 | routes[routePath] = () => |
| 45 | new Response(file, { | 50 | routePath === "/index.html" |
| 46 | headers: { "content-type": file.type || "application/octet-stream" }, | 51 | ? htmlResponse(file) |
| 47 | }); | 52 | : new Response(file, { |
| 53 | headers: { "content-type": file.type || "application/octet-stream" }, | ||
| 54 | }); | ||
| 48 | } | 55 | } |
| 49 | } | 56 | } |
| 50 | } catch {} | 57 | } catch {} |
| @@ -60,9 +67,11 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 9 unmodified lines | |||
| 60 | for (const [key, bunfsPath] of Object.entries(embedded)) { | 67 | for (const [key, bunfsPath] of Object.entries(embedded)) { |
| 61 | const file = Bun.file(bunfsPath); | 68 | const file = Bun.file(bunfsPath); |
| 62 | routes[`/${key}`] = () => | 69 | routes[`/${key}`] = () => |
| 63 | new Response(file, { | 70 | key === "index.html" |
| 64 | headers: { "content-type": file.type || "application/octet-stream" }, | 71 | ? htmlResponse(file) |
| 65 | }); | 72 | : new Response(file, { |
| 73 | headers: { "content-type": file.type || "application/octet-stream" }, | ||
| 74 | }); | ||
| 66 | } | 75 | } |
| 67 | return routes; | 76 | return routes; |
| 68 | } | 77 | } |
| @@ -300,11 +309,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 231 unmodified lines | |||
| 300 | } else if (opts.embeddedWebUI) { | 309 | } else if (opts.embeddedWebUI) { |
| 301 | staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); | 310 | staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); |
| 302 | const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); | 311 | const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); |
| 303 | serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } }); | 312 | serveIndex = () => htmlResponse(indexFile); |
| 304 | } else { | 313 | } else { |
| 305 | staticRoutes = collectStaticRoutes(clientDir); | 314 | staticRoutes = collectStaticRoutes(clientDir); |
| 306 | const indexFile = Bun.file(join(clientDir, "index.html")); | 315 | const indexFile = Bun.file(join(clientDir, "index.html")); |
| 307 | serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } }); | 316 | serveIndex = () => htmlResponse(indexFile); |
| 308 | } | 317 | } |
| 309 | 318 | ||
| 310 | const server = Bun.serve({ | 319 | const server = Bun.serve({ |
| @@ -42,6 +42,7 @@ · 41 unmodified lines | |||
| 42 | "@executor-js/runtime-quickjs": "workspace:*", | 42 | "@executor-js/runtime-quickjs": "workspace:*", |
| 43 | "@executor-js/sdk": "workspace:*", | 43 | "@executor-js/sdk": "workspace:*", |
| 44 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | 44 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 45 | "@sentry/bun": "^10.57.0", | ||
| 45 | "effect": "catalog:", | 46 | "effect": "catalog:", |
| 46 | "quickjs-emscripten": "catalog:", | 47 | "quickjs-emscripten": "catalog:", |
| 47 | }, | 48 | }, |
| @@ -0,0 +1,109 @@ | |||
| 1 | /* oxlint-disable executor/no-conditional-tests -- e2e scenario uses try/finally to restore the VM service after assertions */ | ||
| 2 | // Real VM e2e for the upgrade path: `executor service install` must take over | ||
| 3 | // a same-data-dir predecessor instead of refusing and leaving users to find a | ||
| 4 | // pid. Runs on the tart-backed Unix CLI targets where the test worker can SSH | ||
| 5 | // into the guest that globalsetup provisioned. | ||
| 6 | import { execFile } from "node:child_process"; | ||
| 7 | import { promisify } from "node:util"; | ||
| 8 | |||
| 9 | import { expect, it } from "@effect/vitest"; | ||
| 10 | import { Effect } from "effect"; | ||
| 11 | |||
| 12 | import { scenario } from "../src/scenario"; | ||
| 13 | |||
| 14 | const execFileAsync = promisify(execFile); | ||
| 15 | const PORT = 4789; | ||
| 16 | |||
| 17 | const SSH_OPTS = [ | ||
| 18 | "-o", | ||
| 19 | "StrictHostKeyChecking=no", | ||
| 20 | "-o", | ||
| 21 | "UserKnownHostsFile=/dev/null", | ||
| 22 | "-o", | ||
| 23 | "ConnectTimeout=8", | ||
| 24 | "-o", | ||
| 25 | "ServerAliveInterval=5", | ||
| 26 | "-o", | ||
| 27 | "LogLevel=ERROR", | ||
| 28 | ] as const; | ||
| 29 | |||
| 30 | const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { | ||
| 31 | const host = process.env.E2E_CLI_VM_HOST; | ||
| 32 | const os = process.env.E2E_VM_OS; | ||
| 33 | if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); | ||
| 34 | const wrapped = | ||
| 35 | os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; | ||
| 36 | try { | ||
| 37 | const { stdout, stderr } = await execFileAsync( | ||
| 38 | process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", | ||
| 39 | ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${host}`, wrapped], | ||
| 40 | { maxBuffer: 32 * 1024 * 1024 }, | ||
| 41 | ); | ||
| 42 | return { stdout, stderr, code: 0 }; | ||
| 43 | } catch (error) { | ||
| 44 | const err = error as { stdout?: string; stderr?: string; code?: number }; | ||
| 45 | return { | ||
| 46 | stdout: err.stdout ?? "", | ||
| 47 | stderr: err.stderr ?? "", | ||
| 48 | code: typeof err.code === "number" ? err.code : 1, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | }; | ||
| 52 | |||
| 53 | const waitForGuestHealth = async (expected: boolean): Promise<boolean> => { | ||
| 54 | const deadline = Date.now() + 30_000; | ||
| 55 | for (;;) { | ||
| 56 | const result = await ssh( | ||
| 57 | `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`, | ||
| 58 | ); | ||
| 59 | const healthy = result.stdout.trim() === "200"; | ||
| 60 | if (healthy === expected) return true; | ||
| 61 | if (Date.now() >= deadline) return false; | ||
| 62 | await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| 63 | } | ||
| 64 | }; | ||
| 65 | |||
| 66 | const listenerPid = async (): Promise<string> => | ||
| 67 | (await ssh(`lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`)).stdout.trim(); | ||
| 68 | |||
| 69 | if (process.env.E2E_VM_OS === "windows") { | ||
| 70 | it.skip("CLI service install takeover · Windows coverage uses the restart service matrix", () => {}); | ||
| 71 | } else { | ||
| 72 | scenario( | ||
| 73 | "CLI service install · takes over a running predecessor daemon", | ||
| 74 | { timeout: 180_000 }, | ||
| 75 | Effect.promise(async () => { | ||
| 76 | const exe = `${process.env.E2E_CLI_BIN_DIR ?? "~/ed"}/executor`; | ||
| 77 | try { | ||
| 78 | await ssh(`${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`); | ||
| 79 | expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe( | ||
| 80 | true, | ||
| 81 | ); | ||
| 82 | |||
| 83 | await ssh( | ||
| 84 | `nohup ${exe} daemon run --foreground --port ${PORT} >/tmp/takeover-predecessor.log 2>&1 &`, | ||
| 85 | ); | ||
| 86 | expect(await waitForGuestHealth(true), "predecessor daemon became reachable").toBe(true); | ||
| 87 | const predecessorPid = await listenerPid(); | ||
| 88 | expect(predecessorPid, "predecessor owns the service port").not.toBe(""); | ||
| 89 | |||
| 90 | const install = await ssh(`${exe} service install --port ${PORT}`); | ||
| 91 | expect( | ||
| 92 | install.code, | ||
| 93 | `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`, | ||
| 94 | ).toBe(0); | ||
| 95 | expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true); | ||
| 96 | |||
| 97 | const ownerPid = await listenerPid(); | ||
| 98 | const predecessorAlive = ( | ||
| 99 | await ssh(`kill -0 ${predecessorPid} 2>/dev/null && echo alive || echo dead`) | ||
| 100 | ).stdout.trim(); | ||
| 101 | expect(predecessorAlive, "predecessor process was stopped").toBe("dead"); | ||
| 102 | expect(ownerPid, "the service now owns the port").not.toBe(""); | ||
| 103 | expect(ownerPid, "the service is a different process").not.toBe(predecessorPid); | ||
| 104 | } finally { | ||
| 105 | await ssh(`${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`); | ||
| 106 | } | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | } | ||
| @@ -4,24 +4,22 @@ · 3 unmodified lines | |||
| 4 | // ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the | 4 | // ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the |
| 5 | // attach behavior can ONLY be proven against the packaged artifact. | 5 | // attach behavior can ONLY be proven against the packaged artifact. |
| 6 | // | 6 | // |
| 7 | // We start the daemon as the bundle's OWN compiled `executor-sidecar` (the exact | 7 | // We start the daemon as the bundle's OWN compiled `executor` binary (the exact |
| 8 | // binary a supervised install runs) in EXECUTOR_SUPERVISED mode → it self- | 8 | // binary a supervised install runs) in EXECUTOR_SUPERVISED mode. It publishes a |
| 9 | // publishes a manifest of kind "cli-daemon". Then we launch the packaged app | 9 | // manifest of kind "cli-daemon". Then we launch the packaged app |
| 10 | // pointed at the same HOME and prove it attached: the manifest still names the | 10 | // pointed at the same HOME and prove it attached: the manifest still names the |
| 11 | // daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a | 11 | // daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a |
| 12 | // fresh pid), and the console — served by the bearer-gated daemon — renders, | 12 | // fresh pid), and the console — served by the bearer-gated daemon — renders, |
| 13 | // which only happens if the app injected the bearer it read from the manifest. | 13 | // which only happens if the app injected the bearer it read from the manifest. |
| 14 | // The recording (session.mp4 + screenshots) is the artifact; the waits assert. | 14 | // The recording (session.mp4 + screenshots) is the artifact; the waits assert. |
| 15 | import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; | 15 | import { type ChildProcess, execFileSync, spawn } from "node:child_process"; |
| 16 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | 16 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 17 | import net from "node:net"; | 17 | import net from "node:net"; |
| 18 | import { tmpdir } from "node:os"; | 18 | import { tmpdir } from "node:os"; |
| 19 | import { dirname, join } from "node:path"; | 19 | import { join } from "node:path"; |
| 20 | import { promisify } from "node:util"; | ||
| 21 | 20 | ||
| 22 | import { expect, it } from "@effect/vitest"; | 21 | import { expect, it } from "@effect/vitest"; |
| 23 | import { Effect } from "effect"; | 22 | import { Effect } from "effect"; |
| 24 | import { _electron } from "playwright"; | ||
| 25 | 23 | ||
| 26 | import { scenario } from "../src/scenario"; | 24 | import { scenario } from "../src/scenario"; |
| 27 | import { RunDir } from "../src/services"; | 25 | import { RunDir } from "../src/services"; |
| @@ -50,10 +48,138 @@ const guiAvailable = (): boolean => { · 22 unmodified lines | |||
| 50 | const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; | 48 | const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; |
| 51 | 49 | ||
| 52 | const appExe = process.env.E2E_DESKTOP_APP_EXE; | 50 | const appExe = process.env.E2E_DESKTOP_APP_EXE; |
| 53 | const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; | 51 | const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; |
| 54 | // The bundled web UI sits beside the sidecar in Resources/ (…/sidecar/<bin> → | 52 | |
| 55 | // …/web-ui). The compiled sidecar serves it via EXECUTOR_CLIENT_DIR. | 53 | interface PackagedApp { |
| 56 | const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; | 54 | readonly child: ChildProcess; |
| 55 | readonly debugPort: string; | ||
| 56 | cdp: CdpPage; | ||
| 57 | } | ||
| 58 | |||
| 59 | interface CdpResponse<T> { | ||
| 60 | readonly id: number; | ||
| 61 | readonly result?: T; | ||
| 62 | readonly error?: { readonly message?: string }; | ||
| 63 | } | ||
| 64 | |||
| 65 | interface CdpEvaluateResult { | ||
| 66 | readonly result: { readonly value?: unknown }; | ||
| 67 | readonly exceptionDetails?: unknown; | ||
| 68 | } | ||
| 69 | |||
| 70 | interface CdpTarget { | ||
| 71 | readonly type: string; | ||
| 72 | readonly url: string; | ||
| 73 | readonly webSocketDebuggerUrl?: string; | ||
| 74 | } | ||
| 75 | |||
| 76 | class CdpPage { | ||
| 77 | private nextId = 1; | ||
| 78 | private readonly pending = new Map< | ||
| 79 | number, | ||
| 80 | { | ||
| 81 | readonly resolve: (value: unknown) => void; | ||
| 82 | readonly reject: (error: Error) => void; | ||
| 83 | } | ||
| 84 | >(); | ||
| 85 | |||
| 86 | private constructor(private readonly socket: WebSocket) { | ||
| 87 | socket.addEventListener("message", (event) => { | ||
| 88 | const data = event.data; | ||
| 89 | if (typeof data !== "string") return; | ||
| 90 | const message = JSON.parse(data) as CdpResponse<unknown>; | ||
| 91 | if (!message.id) return; | ||
| 92 | const pending = this.pending.get(message.id); | ||
| 93 | if (!pending) return; | ||
| 94 | this.pending.delete(message.id); | ||
| 95 | if (message.error) { | ||
| 96 | pending.reject(new Error(message.error.message ?? "CDP command failed")); | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | pending.resolve(message.result); | ||
| 100 | }); | ||
| 101 | socket.addEventListener("close", () => { | ||
| 102 | for (const [, pending] of this.pending) { | ||
| 103 | pending.reject(new Error("CDP socket closed")); | ||
| 104 | } | ||
| 105 | this.pending.clear(); | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | |||
| 109 | static connect = (url: string): Promise<CdpPage> => | ||
| 110 | new Promise((resolve, reject) => { | ||
| 111 | const socket = new WebSocket(url); | ||
| 112 | const timer = setTimeout(() => { | ||
| 113 | socket.close(); | ||
| 114 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter | ||
| 115 | reject(new Error(`Timed out connecting to page CDP target ${url}`)); | ||
| 116 | }, 30_000); | ||
| 117 | socket.addEventListener( | ||
| 118 | "open", | ||
| 119 | () => { | ||
| 120 | clearTimeout(timer); | ||
| 121 | resolve(new CdpPage(socket)); | ||
| 122 | }, | ||
| 123 | { once: true }, | ||
| 124 | ); | ||
| 125 | socket.addEventListener( | ||
| 126 | "error", | ||
| 127 | () => { | ||
| 128 | clearTimeout(timer); | ||
| 129 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter | ||
| 130 | reject(new Error(`Failed to connect to page CDP target ${url}`)); | ||
| 131 | }, | ||
| 132 | { once: true }, | ||
| 133 | ); | ||
| 134 | }); | ||
| 135 | |||
| 136 | command = async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => { | ||
| 137 | const id = this.nextId; | ||
| 138 | this.nextId += 1; | ||
| 139 | const result = new Promise<T>((resolve, reject) => { | ||
| 140 | this.pending.set(id, { | ||
| 141 | resolve: (value) => resolve(value as T), | ||
| 142 | reject, | ||
| 143 | }); | ||
| 144 | }); | ||
| 145 | this.socket.send(JSON.stringify({ id, method, params })); | ||
| 146 | return result; | ||
| 147 | }; | ||
| 148 | |||
| 149 | evaluate = async <T>(expression: string): Promise<T> => { | ||
| 150 | const result = await this.command<CdpEvaluateResult>("Runtime.evaluate", { | ||
| 151 | expression, | ||
| 152 | awaitPromise: true, | ||
| 153 | returnByValue: true, | ||
| 154 | }); | ||
| 155 | if (result.exceptionDetails) { | ||
| 156 | throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); | ||
| 157 | } | ||
| 158 | return result.result.value as T; | ||
| 159 | }; | ||
| 160 | |||
| 161 | waitForText = async (text: string, timeoutMs: number): Promise<void> => { | ||
| 162 | const deadline = Date.now() + timeoutMs; | ||
| 163 | const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`; | ||
| 164 | for (;;) { | ||
| 165 | if (await this.evaluate<boolean>(expression).catch(() => false)) return; | ||
| 166 | if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`); | ||
| 167 | await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| 168 | } | ||
| 169 | }; | ||
| 170 | |||
| 171 | screenshot = async (path: string): Promise<void> => { | ||
| 172 | const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", { | ||
| 173 | format: "png", | ||
| 174 | fromSurface: true, | ||
| 175 | }); | ||
| 176 | writeFileSync(path, Buffer.from(result.data, "base64")); | ||
| 177 | }; | ||
| 178 | |||
| 179 | close = (): void => { | ||
| 180 | this.socket.close(); | ||
| 181 | }; | ||
| 182 | } | ||
| 57 | 183 | ||
| 58 | const freePort = (): Promise<number> => | 184 | const freePort = (): Promise<number> => |
| 59 | new Promise((resolve, reject) => { | 185 | new Promise((resolve, reject) => { |
| @@ -76,16 +202,20 @@ interface DaemonStart { · 16 unmodified lines | |||
| 76 | readonly stderr: string; | 202 | readonly stderr: string; |
| 77 | } | 203 | } |
| 78 | 204 | ||
| 79 | /** Spawn the bundle's compiled sidecar as a supervised daemon; resolves once it | 205 | /** Spawn the bundle's compiled executor as a supervised daemon; resolves once it |
| 80 | * announces EXECUTOR_READY (or times out / exits early, ready:false). */ | 206 | * announces readiness (or times out / exits early, ready:false). */ |
| 81 | const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => | 207 | const startSupervisedDaemon = (env: NodeJS.ProcessEnv, port: number): Promise<DaemonStart> => |
| 82 | new Promise((resolve) => { | 208 | new Promise((resolve) => { |
| 83 | const child = spawn(sidecarBin as string, [], { env, stdio: ["ignore", "pipe", "pipe"] }); | 209 | const child = spawn( |
| 210 | executorBin as string, | ||
| 211 | ["daemon", "run", "--foreground", "--port", String(port), "--hostname", "127.0.0.1"], | ||
| 212 | { env, stdio: ["ignore", "pipe", "pipe"] }, | ||
| 213 | ); | ||
| 84 | let stderr = ""; | 214 | let stderr = ""; |
| 85 | const settle = (ready: boolean) => resolve({ child, ready, stderr }); | 215 | const settle = (ready: boolean) => resolve({ child, ready, stderr }); |
| 86 | const timer = setTimeout(() => settle(false), 60_000); | 216 | const timer = setTimeout(() => settle(false), 60_000); |
| 87 | child.stdout.on("data", (chunk: Buffer) => { | 217 | child.stdout.on("data", (chunk: Buffer) => { |
| 88 | if (chunk.toString().includes("EXECUTOR_READY:")) { | 218 | if (/Daemon ready on http:\/\//.test(chunk.toString())) { |
| 89 | clearTimeout(timer); | 219 | clearTimeout(timer); |
| 90 | settle(true); | 220 | settle(true); |
| 91 | } | 221 | } |
| @@ -99,16 +229,117 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 7 unmodified lines | |||
| 99 | }); | 229 | }); |
| 100 | }); | 230 | }); |
| 101 | 231 | ||
| 102 | if (!guiAvailable()) { | 232 | const packagedSingleInstanceAvailable = (): boolean => { |
| 103 | it.skip(`${SCENARIO_NAME} (needs a GUI display — Aqua / X / Wayland)`, () => {}); | 233 | if (process.platform !== "darwin" || !appExe) return true; |
| 234 | try { | ||
| 235 | const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], { | ||
| 236 | encoding: "utf8", | ||
| 237 | }) | ||
| 238 | .split("\n") | ||
| 239 | .filter(Boolean); | ||
| 240 | return !lines.some((line) => !line.includes(appExe)); | ||
| 241 | } catch { | ||
| 242 | return true; | ||
| 243 | } | ||
| 244 | }; | ||
| 245 | |||
| 246 | const waitForPageWebSocket = async (debugPort: string): Promise<string> => { | ||
| 247 | const deadline = Date.now() + 120_000; | ||
| 248 | for (;;) { | ||
| 249 | const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`) | ||
| 250 | .then((response) => (response.ok ? response.json() : [])) | ||
| 251 | .catch(() => [])) as ReadonlyArray<CdpTarget>; | ||
| 252 | const page = targets.find( | ||
| 253 | (target) => | ||
| 254 | target.type === "page" && | ||
| 255 | target.webSocketDebuggerUrl && | ||
| 256 | !target.url.startsWith("devtools://"), | ||
| 257 | ); | ||
| 258 | if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; | ||
| 259 | if (Date.now() >= deadline) { | ||
| 260 | throw new Error("Timed out waiting for packaged app page CDP target"); | ||
| 261 | } | ||
| 262 | await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| 263 | } | ||
| 264 | }; | ||
| 265 | |||
| 266 | const launchPackaged = async (home: string): Promise<PackagedApp> => { | ||
| 267 | let output = ""; | ||
| 268 | let settled = false; | ||
| 269 | const child = spawn(appExe as string, ["--remote-debugging-port=0"], { | ||
| 270 | env: { ...process.env, HOME: home }, | ||
| 271 | stdio: ["ignore", "pipe", "pipe"], | ||
| 272 | }); | ||
| 273 | |||
| 274 | const browserCdpUrl = await new Promise<string>((resolve, reject) => { | ||
| 275 | const timer = setTimeout(() => { | ||
| 276 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter | ||
| 277 | reject(new Error(`Timed out waiting for packaged app CDP URL\n${output}`)); | ||
| 278 | }, 120_000); | ||
| 279 | const settle = (fn: () => void) => { | ||
| 280 | if (settled) return; | ||
| 281 | settled = true; | ||
| 282 | clearTimeout(timer); | ||
| 283 | fn(); | ||
| 284 | }; | ||
| 285 | const collectOutput = (chunk: Buffer) => { | ||
| 286 | const text = chunk.toString(); | ||
| 287 | output = (output + text).slice(-16_384); | ||
| 288 | const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/); | ||
| 289 | if (match) settle(() => resolve(match[1]!)); | ||
| 290 | }; | ||
| 291 | child.stdout?.on("data", collectOutput); | ||
| 292 | child.stderr?.on("data", collectOutput); | ||
| 293 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: packaged-app launch promise adapter | ||
| 294 | child.once("error", (error) => settle(() => reject(error))); | ||
| 295 | child.once("exit", (code, signal) => | ||
| 296 | settle(() => | ||
| 297 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter | ||
| 298 | reject( | ||
| 299 | new Error(`Packaged app exited before CDP (code=${code} signal=${signal})\n${output}`), | ||
| 300 | ), | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | }); | ||
| 304 | |||
| 305 | const debugPort = new URL(browserCdpUrl).port; | ||
| 306 | const pageCdpUrl = await waitForPageWebSocket(debugPort); | ||
| 307 | const cdp = await CdpPage.connect(pageCdpUrl); | ||
| 308 | await cdp.command("Runtime.enable"); | ||
| 309 | await cdp.command("Page.enable"); | ||
| 310 | return { child, cdp, debugPort }; | ||
| 311 | }; | ||
| 312 | |||
| 313 | const stopProcess = async (child: ChildProcess | undefined): Promise<void> => { | ||
| 314 | if (!child || child.exitCode !== null || child.signalCode !== null) return; | ||
| 315 | await new Promise<void>((resolve) => { | ||
| 316 | const timeout = setTimeout(() => { | ||
| 317 | child.kill("SIGKILL"); | ||
| 318 | resolve(); | ||
| 319 | }, 5_000); | ||
| 320 | child.once("exit", () => { | ||
| 321 | clearTimeout(timeout); | ||
| 322 | resolve(); | ||
| 323 | }); | ||
| 324 | child.kill("SIGTERM"); | ||
| 325 | }); | ||
| 326 | }; | ||
| 327 | |||
| 328 | const closePackaged = async (app: PackagedApp | undefined): Promise<void> => { | ||
| 329 | app?.cdp.close(); | ||
| 330 | await stopProcess(app?.child); | ||
| 331 | }; | ||
| 332 | |||
| 333 | if (!guiAvailable() || !packagedSingleInstanceAvailable()) { | ||
| 334 | it.skip(`${SCENARIO_NAME} (needs a GUI display and no already-running Executor.app)`, () => {}); | ||
| 104 | } else { | 335 | } else { |
| 105 | scenario( | 336 | scenario( |
| 106 | SCENARIO_NAME, | 337 | SCENARIO_NAME, |
| 107 | { timeout: 240_000 }, | 338 | { timeout: 240_000 }, |
| 108 | Effect.gen(function* () { | 339 | Effect.gen(function* () { |
| 109 | if (!appExe || !sidecarBin) { | 340 | if (!appExe || !executorBin) { |
| 110 | return yield* Effect.die( | 341 | return yield* Effect.die( |
| 111 | "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", | 342 | "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?", |
| 112 | ); | 343 | ); |
| 113 | } | 344 | } |
| 114 | const runDir = yield* RunDir; | 345 | const runDir = yield* RunDir; |
| @@ -121,58 +352,50 @@ const run = async (runDir: string) => { · 6 unmodified lines | |||
| 121 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-")); | 352 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-")); |
| 122 | const dataDir = join(home, ".executor"); | 353 | const dataDir = join(home, ".executor"); |
| 123 | const manifestPath = join(dataDir, "server-control", "server.json"); | 354 | const manifestPath = join(dataDir, "server-control", "server.json"); |
| 124 | const videoTmp = join(runDir, ".video-tmp"); | ||
| 125 | const port = await freePort(); | 355 | const port = await freePort(); |
| 126 | 356 | ||
| 127 | let daemon: ChildProcess | undefined; | 357 | let daemon: ChildProcess | undefined; |
| 128 | let app: Awaited<ReturnType<typeof _electron.launch>> | undefined; | 358 | let app: PackagedApp | undefined; |
| 129 | let stepIndex = 0; | 359 | let stepIndex = 0; |
| 130 | 360 | ||
| 131 | try { | 361 | try { |
| 132 | const started = await startSupervisedDaemon({ | 362 | const started = await startSupervisedDaemon( |
| 133 | ...process.env, | 363 | { |
| 134 | HOME: home, | 364 | ...process.env, |
| 135 | EXECUTOR_SUPERVISED: "1", | 365 | HOME: home, |
| 136 | EXECUTOR_DATA_DIR: dataDir, | 366 | EXECUTOR_SUPERVISED: "1", |
| 137 | EXECUTOR_PORT: String(port), | 367 | EXECUTOR_DATA_DIR: dataDir, |
| 138 | EXECUTOR_HOST: "127.0.0.1", | 368 | EXECUTOR_AUTH_TOKEN: "packaged-attach-film", |
| 139 | EXECUTOR_AUTH_TOKEN: "packaged-attach-film", | 369 | EXECUTOR_CLIENT: "desktop", |
| 140 | EXECUTOR_CLIENT_DIR: clientDir, | 370 | }, |
| 141 | }); | 371 | port, |
| 372 | ); | ||
| 142 | daemon = started.child; | 373 | daemon = started.child; |
| 143 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); | 374 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); |
| 144 | await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); | 375 | await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); |
| 145 | 376 | ||
| 146 | const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; | 377 | const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; |
| 147 | expect(daemonManifest.kind, "the compiled sidecar advertises itself as cli-daemon").toBe( | 378 | expect(daemonManifest.kind, "the bundled executor advertises itself as cli-daemon").toBe( |
| 148 | "cli-daemon", | 379 | "cli-daemon", |
| 149 | ); | 380 | ); |
| 150 | const daemonPid = daemonManifest.pid; | 381 | const daemonPid = daemonManifest.pid; |
| 151 | 382 | ||
| 152 | // Launch the PACKAGED bundle (executablePath = the installed app binary, no | 383 | // Launch the PACKAGED bundle directly. `app.isPackaged` is true, so boot() |
| 153 | // app-dir arg) → app.isPackaged is true → boot() runs the supervised attach. | 384 | // runs the supervised attach path; CDP drives the real renderer. |
| 154 | app = await _electron.launch({ | 385 | app = await launchPackaged(home); |
| 155 | executablePath: appExe as string, | 386 | const page = app.cdp; |
| 156 | env: { ...process.env, HOME: home }, | ||
| 157 | recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, | ||
| 158 | timeout: 120_000, | ||
| 159 | }); | ||
| 160 | |||
| 161 | const page = await app.firstWindow({ timeout: 120_000 }); | ||
| 162 | const step = async (label: string, body: () => Promise<void>) => { | 387 | const step = async (label: string, body: () => Promise<void>) => { |
| 163 | await body(); | 388 | await body(); |
| 164 | stepIndex += 1; | 389 | stepIndex += 1; |
| 165 | const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); | 390 | const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); |
| 166 | await page.screenshot({ | 391 | await page.screenshot(join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`)); |
| 167 | path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), | ||
| 168 | }); | ||
| 169 | }; | 392 | }; |
| 170 | 393 | ||
| 171 | // The console only renders once the app has a live connection AND the bearer | 394 | // The console only renders once the app has a live connection AND the bearer |
| 172 | // it injects is accepted by the gated daemon — so reaching it proves both the | 395 | // it injects is accepted by the gated daemon — so reaching it proves both the |
| 173 | // attach and the bearer wiring through the packaged session layer. | 396 | // attach and the bearer wiring through the packaged session layer. |
| 174 | await step("packaged app boots into the bearer-gated console", async () => { | 397 | await step("packaged app boots into the bearer-gated console", async () => { |
| 175 | await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); | 398 | await page.waitForText("Settings", 120_000); |
| 176 | }); | 399 | }); |
| 177 | 400 | ||
| 178 | // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still | 401 | // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still |
| @@ -185,30 +408,8 @@ const run = async (runDir: string) => { · 6 unmodified lines | |||
| 185 | ); | 408 | ); |
| 186 | }); | 409 | }); |
| 187 | } finally { | 410 | } finally { |
| 188 | const page = app?.windows()[0]; | 411 | await closePackaged(app); |
| 189 | const video = page?.video(); | 412 | await stopProcess(daemon); |
| 190 | await app?.close().catch(() => {}); | ||
| 191 | const recordedPath = await video?.path().catch(() => undefined); | ||
| 192 | if (recordedPath && existsSync(recordedPath)) { | ||
| 193 | await promisify(execFile)("ffmpeg", [ | ||
| 194 | "-y", | ||
| 195 | "-i", | ||
| 196 | recordedPath, | ||
| 197 | "-c:v", | ||
| 198 | "libx264", | ||
| 199 | "-preset", | ||
| 200 | "veryfast", | ||
| 201 | "-crf", | ||
| 202 | "26", | ||
| 203 | "-pix_fmt", | ||
| 204 | "yuv420p", | ||
| 205 | "-movflags", | ||
| 206 | "+faststart", | ||
| 207 | join(runDir, "session.mp4"), | ||
| 208 | ]).catch(() => {}); | ||
| 209 | } | ||
| 210 | daemon?.kill("SIGTERM"); | ||
| 211 | rmSync(videoTmp, { recursive: true, force: true }); | ||
| 212 | rmSync(home, { recursive: true, force: true }); | 413 | rmSync(home, { recursive: true, force: true }); |
| 213 | } | 414 | } |
| 214 | }; | 415 | }; |
| @@ -1,17 +1,25 @@ | |||
| 1 | // Packaged desktop supervised-daemon regressions. These run against the real | 1 | // Packaged desktop supervised-daemon regressions. These run against the real |
| 2 | // electron-builder bundle and its compiled sidecar because the supervised attach | 2 | // electron-builder bundle and its bundled executor because the supervised attach |
| 3 | // path is production-only (`app.isPackaged`). | 3 | // path is production-only (`app.isPackaged`). |
| 4 | import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; | 4 | import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; |
| 5 | import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; | 5 | import { |
| 6 | chmodSync, | ||
| 7 | existsSync, | ||
| 8 | mkdirSync, | ||
| 9 | mkdtempSync, | ||
| 10 | readFileSync, | ||
| 11 | rmSync, | ||
| 12 | statSync, | ||
| 13 | writeFileSync, | ||
| 14 | } from "node:fs"; | ||
| 6 | import { createServer, type IncomingMessage } from "node:http"; | 15 | import { createServer, type IncomingMessage } from "node:http"; |
| 7 | import net from "node:net"; | 16 | import net from "node:net"; |
| 8 | import { tmpdir } from "node:os"; | 17 | import { homedir, tmpdir } from "node:os"; |
| 9 | import { dirname, join } from "node:path"; | 18 | import { dirname, join } from "node:path"; |
| 10 | import { promisify } from "node:util"; | 19 | import { promisify } from "node:util"; |
| 11 | 20 | ||
| 12 | import { expect, it } from "@effect/vitest"; | 21 | import { expect, it } from "@effect/vitest"; |
| 13 | import { Effect } from "effect"; | 22 | import { Effect } from "effect"; |
| 14 | import { _electron, type ElectronApplication } from "playwright"; | ||
| 15 | import { | 23 | import { |
| 16 | normalizeExecutorServerConnection, | 24 | normalizeExecutorServerConnection, |
| 17 | serializeExecutorLocalServerManifest, | 25 | serializeExecutorLocalServerManifest, |
| @@ -21,6 +29,9 @@ import { scenario } from "../src/scenario"; · 3 unmodified lines | |||
| 21 | import { RunDir } from "../src/services"; | 29 | import { RunDir } from "../src/services"; |
| 22 | import { waitForHttp } from "../setup/boot"; | 30 | import { waitForHttp } from "../setup/boot"; |
| 23 | 31 | ||
| 32 | const execFileAsync = promisify(execFile); | ||
| 33 | const SERVICE_LABEL = "sh.executor.daemon"; | ||
| 34 | |||
| 24 | interface PackagedExecutorBridge { | 35 | interface PackagedExecutorBridge { |
| 25 | readonly getSettings: () => Promise<{ readonly port: number }>; | 36 | readonly getSettings: () => Promise<{ readonly port: number }>; |
| 26 | readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>; | 37 | readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>; |
| @@ -28,6 +39,173 @@ interface PackagedExecutorBridge { · 1 unmodified line | |||
| 28 | readonly getServerConnection: () => Promise<{ readonly origin: string } | null>; | 39 | readonly getServerConnection: () => Promise<{ readonly origin: string } | null>; |
| 29 | } | 40 | } |
| 30 | 41 | ||
| 42 | interface PackagedApp { | ||
| 43 | readonly child: ChildProcess; | ||
| 44 | cdp: CdpPage; | ||
| 45 | readonly debugPort: string; | ||
| 46 | readonly output: () => string; | ||
| 47 | } | ||
| 48 | |||
| 49 | interface CdpResponse<T> { | ||
| 50 | readonly id: number; | ||
| 51 | readonly result?: T; | ||
| 52 | readonly error?: { readonly message?: string; readonly data?: string }; | ||
| 53 | } | ||
| 54 | |||
| 55 | interface CdpEvaluateResult { | ||
| 56 | readonly result: { readonly value?: unknown }; | ||
| 57 | readonly exceptionDetails?: unknown; | ||
| 58 | } | ||
| 59 | |||
| 60 | interface CdpTarget { | ||
| 61 | readonly type: string; | ||
| 62 | readonly url: string; | ||
| 63 | readonly webSocketDebuggerUrl?: string; | ||
| 64 | } | ||
| 65 | |||
| 66 | class CdpPage { | ||
| 67 | private nextId = 1; | ||
| 68 | private readonly pending = new Map< | ||
| 69 | number, | ||
| 70 | { | ||
| 71 | readonly resolve: (value: unknown) => void; | ||
| 72 | readonly reject: (error: Error) => void; | ||
| 73 | } | ||
| 74 | >(); | ||
| 75 | |||
| 76 | private constructor(private readonly socket: WebSocket) { | ||
| 77 | socket.addEventListener("message", (event) => { | ||
| 78 | const data = event.data; | ||
| 79 | if (typeof data !== "string") return; | ||
| 80 | const message = JSON.parse(data) as CdpResponse<unknown>; | ||
| 81 | if (!message.id) return; | ||
| 82 | const pending = this.pending.get(message.id); | ||
| 83 | if (!pending) return; | ||
| 84 | this.pending.delete(message.id); | ||
| 85 | if (message.error) { | ||
| 86 | pending.reject(new Error(message.error.message ?? "CDP command failed")); | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | pending.resolve(message.result); | ||
| 90 | }); | ||
| 91 | socket.addEventListener("close", () => { | ||
| 92 | for (const [, pending] of this.pending) { | ||
| 93 | pending.reject(new Error("CDP socket closed")); | ||
| 94 | } | ||
| 95 | this.pending.clear(); | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | |||
| 99 | static connect = (url: string): Promise<CdpPage> => | ||
| 100 | new Promise((resolve, reject) => { | ||
| 101 | const socket = new WebSocket(url); | ||
| 102 | const timer = setTimeout(() => { | ||
| 103 | socket.close(); | ||
| 104 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter | ||
| 105 | reject(new Error(`Timed out connecting to page CDP target ${url}`)); | ||
| 106 | }, 30_000); | ||
| 107 | socket.addEventListener( | ||
| 108 | "open", | ||
| 109 | () => { | ||
| 110 | clearTimeout(timer); | ||
| 111 | resolve(new CdpPage(socket)); | ||
| 112 | }, | ||
| 113 | { once: true }, | ||
| 114 | ); | ||
| 115 | socket.addEventListener( | ||
| 116 | "error", | ||
| 117 | () => { | ||
| 118 | clearTimeout(timer); | ||
| 119 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter | ||
| 120 | reject(new Error(`Failed to connect to page CDP target ${url}`)); | ||
| 121 | }, | ||
| 122 | { once: true }, | ||
| 123 | ); | ||
| 124 | }); | ||
| 125 | |||
| 126 | command = async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => { | ||
| 127 | const id = this.nextId; | ||
| 128 | this.nextId += 1; | ||
| 129 | const result = new Promise<T>((resolve, reject) => { | ||
| 130 | this.pending.set(id, { | ||
| 131 | resolve: (value) => resolve(value as T), | ||
| 132 | reject, | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | this.socket.send(JSON.stringify({ id, method, params })); | ||
| 136 | return result; | ||
| 137 | }; | ||
| 138 | |||
| 139 | evaluate = async <T>(expression: string): Promise<T> => { | ||
| 140 | const result = await this.command<CdpEvaluateResult>("Runtime.evaluate", { | ||
| 141 | expression, | ||
| 142 | awaitPromise: true, | ||
| 143 | returnByValue: true, | ||
| 144 | }); | ||
| 145 | if (result.exceptionDetails) { | ||
| 146 | throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); | ||
| 147 | } | ||
| 148 | return result.result.value as T; | ||
| 149 | }; | ||
| 150 | |||
| 151 | waitForText = async (text: string, timeoutMs: number): Promise<void> => { | ||
| 152 | const deadline = Date.now() + timeoutMs; | ||
| 153 | const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`; | ||
| 154 | for (;;) { | ||
| 155 | if (await this.evaluate<boolean>(expression).catch(() => false)) return; | ||
| 156 | if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`); | ||
| 157 | await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| 158 | } | ||
| 159 | }; | ||
| 160 | |||
| 161 | waitForExpression = async ( | ||
| 162 | expression: string, | ||
| 163 | timeoutMs: number, | ||
| 164 | description: string, | ||
| 165 | ): Promise<void> => { | ||
| 166 | const deadline = Date.now() + timeoutMs; | ||
| 167 | for (;;) { | ||
| 168 | if (await this.evaluate<boolean>(`Boolean(${expression})`).catch(() => false)) return; | ||
| 169 | if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`); | ||
| 170 | await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| 171 | } | ||
| 172 | }; | ||
| 173 | |||
| 174 | textPresent = async (text: string): Promise<boolean> => | ||
| 175 | this.evaluate<boolean>(`document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`); | ||
| 176 | |||
| 177 | setViewport = async (width: number, height: number): Promise<void> => { | ||
| 178 | await this.command("Emulation.setDeviceMetricsOverride", { | ||
| 179 | width, | ||
| 180 | height, | ||
| 181 | deviceScaleFactor: 1, | ||
| 182 | mobile: false, | ||
| 183 | }); | ||
| 184 | }; | ||
| 185 | |||
| 186 | wheel = async (x: number, y: number, deltaY: number): Promise<void> => { | ||
| 187 | await this.command("Input.dispatchMouseEvent", { | ||
| 188 | type: "mouseWheel", | ||
| 189 | x, | ||
| 190 | y, | ||
| 191 | deltaX: 0, | ||
| 192 | deltaY, | ||
| 193 | }); | ||
| 194 | }; | ||
| 195 | |||
| 196 | screenshot = async (path: string): Promise<void> => { | ||
| 197 | const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", { | ||
| 198 | format: "png", | ||
| 199 | fromSurface: true, | ||
| 200 | }); | ||
| 201 | writeFileSync(path, Buffer.from(result.data, "base64")); | ||
| 202 | }; | ||
| 203 | |||
| 204 | close = (): void => { | ||
| 205 | this.socket.close(); | ||
| 206 | }; | ||
| 207 | } | ||
| 208 | |||
| 31 | declare global { | 209 | declare global { |
| 32 | interface Window { | 210 | interface Window { |
| 33 | readonly executor: PackagedExecutorBridge; | 211 | readonly executor: PackagedExecutorBridge; |
| @@ -35,8 +213,7 @@ declare global { · 1 unmodified line | |||
| 35 | } | 213 | } |
| 36 | 214 | ||
| 37 | const appExe = process.env.E2E_DESKTOP_APP_EXE; | 215 | const appExe = process.env.E2E_DESKTOP_APP_EXE; |
| 38 | const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; | 216 | const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; |
| 39 | const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; | ||
| 40 | 217 | ||
| 41 | const guiAvailable = (): boolean => { | 218 | const guiAvailable = (): boolean => { |
| 42 | if (process.platform === "darwin") { | 219 | if (process.platform === "darwin") { |
| @@ -65,13 +242,102 @@ const packagedSingleInstanceAvailable = (): boolean => { · 22 unmodified lines | |||
| 65 | } | 242 | } |
| 66 | }; | 243 | }; |
| 67 | 244 | ||
| 68 | const requireBundle = (): { readonly app: string; readonly sidecar: string } => { | 245 | const requireBundle = (): { readonly app: string; readonly executor: string } => { |
| 69 | if (!appExe || !sidecarBin) { | 246 | if (!appExe || !executorBin) { |
| 70 | throw new Error( | 247 | throw new Error( |
| 71 | "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", | 248 | "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?", |
| 72 | ); | 249 | ); |
| 73 | } | 250 | } |
| 74 | return { app: appExe, sidecar: sidecarBin }; | 251 | return { app: appExe, executor: executorBin }; |
| 252 | }; | ||
| 253 | |||
| 254 | const currentUid = (): number => { | ||
| 255 | const getuid = (process as { readonly getuid?: () => number }).getuid; | ||
| 256 | return typeof getuid === "function" ? getuid.call(process) : 0; | ||
| 257 | }; | ||
| 258 | |||
| 259 | const serviceTarget = (): string => `gui/${currentUid()}/${SERVICE_LABEL}`; | ||
| 260 | const launchAgentPath = (): string => | ||
| 261 | join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`); | ||
| 262 | const isolatedDesktopSettingsDir = (home: string): string => | ||
| 263 | join(home, ".executor-desktop-settings"); | ||
| 264 | const desktopSettingsDirs = (home: string): readonly string[] => { | ||
| 265 | if (process.platform === "darwin") { | ||
| 266 | const support = join(home, "Library", "Application Support"); | ||
| 267 | return [ | ||
| 268 | isolatedDesktopSettingsDir(home), | ||
| 269 | join(support, "@executor-js", "desktop"), | ||
| 270 | join(support, "Executor"), | ||
| 271 | ]; | ||
| 272 | } | ||
| 273 | if (process.platform === "linux") { | ||
| 274 | return [ | ||
| 275 | isolatedDesktopSettingsDir(home), | ||
| 276 | join(home, ".config", "@executor-js", "desktop"), | ||
| 277 | join(home, ".config", "Executor"), | ||
| 278 | ]; | ||
| 279 | } | ||
| 280 | const roaming = join(home, "AppData", "Roaming"); | ||
| 281 | return [ | ||
| 282 | isolatedDesktopSettingsDir(home), | ||
| 283 | join(roaming, "@executor-js", "desktop"), | ||
| 284 | join(roaming, "Executor"), | ||
| 285 | ]; | ||
| 286 | }; | ||
| 287 | |||
| 288 | const packagedAppEnv = (home: string): NodeJS.ProcessEnv => { | ||
| 289 | return { | ||
| 290 | ...process.env, | ||
| 291 | HOME: home, | ||
| 292 | EXECUTOR_DESKTOP_SETTINGS_DIR: isolatedDesktopSettingsDir(home), | ||
| 293 | }; | ||
| 294 | }; | ||
| 295 | |||
| 296 | interface LaunchdServiceSnapshot { | ||
| 297 | readonly plist: string | null; | ||
| 298 | readonly wasLoaded: boolean; | ||
| 299 | } | ||
| 300 | |||
| 301 | const launchctl = async (args: ReadonlyArray<string>): Promise<boolean> => { | ||
| 302 | try { | ||
| 303 | await execFileAsync("launchctl", [...args]); | ||
| 304 | return true; | ||
| 305 | } catch { | ||
| 306 | return false; | ||
| 307 | } | ||
| 308 | }; | ||
| 309 | |||
| 310 | const captureLaunchdService = (): LaunchdServiceSnapshot | null => { | ||
| 311 | if (process.platform !== "darwin") return null; | ||
| 312 | const path = launchAgentPath(); | ||
| 313 | const plist = existsSync(path) ? readFileSync(path, "utf8") : null; | ||
| 314 | let wasLoaded = false; | ||
| 315 | try { | ||
| 316 | execFileSync("launchctl", ["print", serviceTarget()], { stdio: "ignore" }); | ||
| 317 | wasLoaded = true; | ||
| 318 | } catch { | ||
| 319 | wasLoaded = false; | ||
| 320 | } | ||
| 321 | return { plist, wasLoaded }; | ||
| 322 | }; | ||
| 323 | |||
| 324 | const restoreLaunchdService = async (snapshot: LaunchdServiceSnapshot | null): Promise<void> => { | ||
| 325 | if (!snapshot) return; | ||
| 326 | const target = serviceTarget(); | ||
| 327 | await launchctl(["bootout", target]); | ||
| 328 | const path = launchAgentPath(); | ||
| 329 | if (snapshot.plist === null) { | ||
| 330 | rmSync(path, { force: true }); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | mkdirSync(dirname(path), { recursive: true }); | ||
| 334 | writeFileSync(path, snapshot.plist, { mode: 0o600 }); | ||
| 335 | chmodSync(path, 0o600); | ||
| 336 | await launchctl(["enable", target]); | ||
| 337 | if (snapshot.wasLoaded) { | ||
| 338 | const bootstrapped = await launchctl(["bootstrap", `gui/${currentUid()}`, path]); | ||
| 339 | if (bootstrapped) await launchctl(["kickstart", "-k", target]); | ||
| 340 | } | ||
| 75 | }; | 341 | }; |
| 76 | 342 | ||
| 77 | const freePort = (): Promise<number> => | 343 | const freePort = (): Promise<number> => |
| @@ -90,10 +356,18 @@ interface DaemonStart { · 12 unmodified lines | |||
| 90 | readonly stderr: string; | 356 | readonly stderr: string; |
| 91 | } | 357 | } |
| 92 | 358 | ||
| 93 | const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => | 359 | const startSupervisedDaemon = ( |
| 360 | env: NodeJS.ProcessEnv, | ||
| 361 | port: number, | ||
| 362 | hostname = "127.0.0.1", | ||
| 363 | ): Promise<DaemonStart> => | ||
| 94 | new Promise((resolve) => { | 364 | new Promise((resolve) => { |
| 95 | const { sidecar } = requireBundle(); | 365 | const { executor } = requireBundle(); |
| 96 | const child = spawn(sidecar, [], { env, stdio: ["ignore", "pipe", "pipe"] }); | 366 | const child = spawn( |
| 367 | executor, | ||
| 368 | ["daemon", "run", "--foreground", "--port", String(port), "--hostname", hostname], | ||
| 369 | { env, stdio: ["ignore", "pipe", "pipe"] }, | ||
| 370 | ); | ||
| 97 | let stderr = ""; | 371 | let stderr = ""; |
| 98 | let settled = false; | 372 | let settled = false; |
| 99 | const settle = (ready: boolean) => { | 373 | const settle = (ready: boolean) => { |
| @@ -103,7 +377,7 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 3 unmodified lines | |||
| 103 | }; | 377 | }; |
| 104 | const timer = setTimeout(() => settle(false), 60_000); | 378 | const timer = setTimeout(() => settle(false), 60_000); |
| 105 | child.stdout.on("data", (chunk: Buffer) => { | 379 | child.stdout.on("data", (chunk: Buffer) => { |
| 106 | if (chunk.toString().includes("EXECUTOR_READY:")) { | 380 | if (/Daemon ready on http:\/\//.test(chunk.toString())) { |
| 107 | clearTimeout(timer); | 381 | clearTimeout(timer); |
| 108 | settle(true); | 382 | settle(true); |
| 109 | } | 383 | } |
| @@ -117,34 +391,228 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 7 unmodified lines | |||
| 117 | }); | 391 | }); |
| 118 | }); | 392 | }); |
| 119 | 393 | ||
| 120 | const closeWithVideo = async ( | 394 | const stopProcess = async (child: ChildProcess | undefined): Promise<void> => { |
| 121 | app: ElectronApplication | undefined, | 395 | if (!child || child.exitCode !== null || child.signalCode !== null) return; |
| 122 | runDir: string, | 396 | await new Promise<void>((resolve) => { |
| 123 | videoTmp: string, | 397 | const timeout = setTimeout(() => { |
| 124 | ) => { | 398 | child.kill("SIGKILL"); |
| 125 | const page = app?.windows()[0]; | 399 | resolve(); |
| 126 | const video = page?.video(); | 400 | }, 5_000); |
| 127 | await app?.close().catch(() => {}); | 401 | child.once("exit", () => { |
| 128 | const recordedPath = await video?.path().catch(() => undefined); | 402 | clearTimeout(timeout); |
| 129 | if (recordedPath) { | 403 | resolve(); |
| 130 | await promisify(execFile)("ffmpeg", [ | 404 | }); |
| 131 | "-y", | 405 | child.kill("SIGTERM"); |
| 132 | "-i", | 406 | }); |
| 133 | recordedPath, | 407 | }; |
| 134 | "-c:v", | 408 | |
| 135 | "libx264", | 409 | const waitForPageWebSocket = async (debugPort: string): Promise<string> => { |
| 136 | "-preset", | 410 | const deadline = Date.now() + 120_000; |
| 137 | "veryfast", | 411 | for (;;) { |
| 138 | "-crf", | 412 | const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`) |
| 139 | "26", | 413 | .then((response) => (response.ok ? response.json() : [])) |
| 140 | "-pix_fmt", | 414 | .catch(() => [])) as ReadonlyArray<CdpTarget>; |
| 141 | "yuv420p", | 415 | const page = targets.find( |
| 142 | "-movflags", | 416 | (target) => |
| 143 | "+faststart", | 417 | target.type === "page" && |
| 144 | join(runDir, "session.mp4"), | 418 | target.webSocketDebuggerUrl && |
| 145 | ]).catch(() => {}); | 419 | !target.url.startsWith("devtools://"), |
| 420 | ); | ||
| 421 | if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; | ||
| 422 | if (Date.now() >= deadline) { | ||
| 423 | throw new Error("Timed out waiting for packaged app page CDP target"); | ||
| 424 | } | ||
| 425 | await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| 426 | } | ||
| 427 | }; | ||
| 428 | |||
| 429 | const launchPackaged = async (home: string): Promise<PackagedApp> => { | ||
| 430 | const { app } = requireBundle(); | ||
| 431 | let output = ""; | ||
| 432 | let settled = false; | ||
| 433 | const child = spawn(app, ["--remote-debugging-port=0"], { | ||
| 434 | env: packagedAppEnv(home), | ||
| 435 | stdio: ["ignore", "pipe", "pipe"], | ||
| 436 | }); | ||
| 437 | |||
| 438 | const browserCdpUrl = await new Promise<string>((resolve, reject) => { | ||
| 439 | const timer = setTimeout(() => { | ||
| 440 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter | ||
| 441 | reject(new Error(`Timed out waiting for packaged app CDP URL\n${output}`)); | ||
| 442 | }, 120_000); | ||
| 443 | const settle = (fn: () => void) => { | ||
| 444 | if (settled) return; | ||
| 445 | settled = true; | ||
| 446 | clearTimeout(timer); | ||
| 447 | fn(); | ||
| 448 | }; | ||
| 449 | const collectOutput = (chunk: Buffer) => { | ||
| 450 | const text = chunk.toString(); | ||
| 451 | output = (output + text).slice(-16_384); | ||
| 452 | const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/); | ||
| 453 | if (match) settle(() => resolve(match[1])); | ||
| 454 | }; | ||
| 455 | child.stdout?.on("data", collectOutput); | ||
| 456 | child.stderr?.on("data", collectOutput); | ||
| 457 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: packaged-app launch promise adapter | ||
| 458 | child.once("error", (error) => settle(() => reject(error))); | ||
| 459 | child.once("exit", (code, signal) => | ||
| 460 | settle(() => | ||
| 461 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter | ||
| 462 | reject( | ||
| 463 | new Error(`Packaged app exited before CDP (code=${code} signal=${signal})\n${output}`), | ||
| 464 | ), | ||
| 465 | ), | ||
| 466 | ); | ||
| 467 | }); | ||
| 468 | |||
| 469 | const debugPort = new URL(browserCdpUrl).port; | ||
| 470 | const pageCdpUrl = await waitForPageWebSocket(debugPort); | ||
| 471 | const cdp = await CdpPage.connect(pageCdpUrl); | ||
| 472 | await cdp.command("Runtime.enable"); | ||
| 473 | await cdp.command("Page.enable"); | ||
| 474 | return { child, cdp, debugPort, output: () => output }; | ||
| 475 | }; | ||
| 476 | |||
| 477 | const reconnectPackagedPage = async (app: PackagedApp): Promise<CdpPage> => { | ||
| 478 | app.cdp.close(); | ||
| 479 | const pageCdpUrl = await waitForPageWebSocket(app.debugPort); | ||
| 480 | const cdp = await CdpPage.connect(pageCdpUrl); | ||
| 481 | await cdp.command("Runtime.enable"); | ||
| 482 | await cdp.command("Page.enable"); | ||
| 483 | app.cdp = cdp; | ||
| 484 | return cdp; | ||
| 485 | }; | ||
| 486 | |||
| 487 | const closePackaged = async (app: PackagedApp | undefined): Promise<void> => { | ||
| 488 | app?.cdp.close(); | ||
| 489 | await stopProcess(app?.child); | ||
| 490 | }; | ||
| 491 | |||
| 492 | const waitUntil = async (predicate: () => boolean, timeoutMs: number): Promise<boolean> => { | ||
| 493 | const deadline = Date.now() + timeoutMs; | ||
| 494 | for (;;) { | ||
| 495 | if (predicate()) return true; | ||
| 496 | if (Date.now() >= deadline) return false; | ||
| 497 | await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| 498 | } | ||
| 499 | }; | ||
| 500 | |||
| 501 | const waitForServerConnectionLabel = async ( | ||
| 502 | page: CdpPage, | ||
| 503 | expectedText: string, | ||
| 504 | timeoutMs: number, | ||
| 505 | ): Promise<string> => { | ||
| 506 | const deadline = Date.now() + timeoutMs; | ||
| 507 | let label = ""; | ||
| 508 | for (;;) { | ||
| 509 | label = await page | ||
| 510 | .evaluate<string>( | ||
| 511 | `document.querySelector('[aria-label^="Select Executor server:"]')?.getAttribute('aria-label') ?? ""`, | ||
| 512 | ) | ||
| 513 | .catch(() => ""); | ||
| 514 | if (label.includes(expectedText)) return label; | ||
| 515 | if (Date.now() >= deadline) { | ||
| 516 | throw new Error( | ||
| 517 | `Timed out waiting for server connection label ${expectedText}; last=${label}`, | ||
| 518 | ); | ||
| 519 | } | ||
| 520 | await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| 521 | } | ||
| 522 | }; | ||
| 523 | |||
| 524 | const settingsScrollFrameExpression = `(() => { | ||
| 525 | const frames = Array.from(document.querySelectorAll("div")); | ||
| 526 | const frame = frames.find((el) => { | ||
| 527 | const style = getComputedStyle(el); | ||
| 528 | const text = el.textContent ?? ""; | ||
| 529 | return style.overflowY === "auto" && | ||
| 530 | el.scrollHeight > el.clientHeight && | ||
| 531 | text.includes("Desktop server connection") && | ||
| 532 | text.includes("CLI profile") && | ||
| 533 | text.includes("Bearer token"); | ||
| 534 | }); | ||
| 535 | if (!frame) return null; | ||
| 536 | return { | ||
| 537 | scrollTop: frame.scrollTop, | ||
| 538 | scrollHeight: frame.scrollHeight, | ||
| 539 | clientHeight: frame.clientHeight, | ||
| 540 | }; | ||
| 541 | })()`; | ||
| 542 | |||
| 543 | const assertDesktopSettingsScrolls = async (page: CdpPage): Promise<void> => { | ||
| 544 | await page.setViewport(900, 420); | ||
| 545 | await page.waitForExpression( | ||
| 546 | `${settingsScrollFrameExpression} !== null`, | ||
| 547 | 30_000, | ||
| 548 | "the desktop settings scroll frame", | ||
| 549 | ); | ||
| 550 | const before = await page.evaluate<{ | ||
| 551 | readonly scrollTop: number; | ||
| 552 | readonly scrollHeight: number; | ||
| 553 | readonly clientHeight: number; | ||
| 554 | }>(settingsScrollFrameExpression); | ||
| 555 | expect( | ||
| 556 | before.scrollHeight, | ||
| 557 | "the settings page should have overflow content in a short desktop window", | ||
| 558 | ).toBeGreaterThan(before.clientHeight); | ||
| 559 | |||
| 560 | await page.wheel(450, 220, 640); | ||
| 561 | await page.waitForExpression( | ||
| 562 | `${settingsScrollFrameExpression}?.scrollTop > ${before.scrollTop}`, | ||
| 563 | 30_000, | ||
| 564 | "desktop settings to scroll after a wheel gesture", | ||
| 565 | ); | ||
| 566 | const after = await page.evaluate<{ | ||
| 567 | readonly scrollTop: number; | ||
| 568 | readonly scrollHeight: number; | ||
| 569 | readonly clientHeight: number; | ||
| 570 | }>(settingsScrollFrameExpression); | ||
| 571 | expect(after.scrollTop, "wheel scrolling should move the settings page").toBeGreaterThan( | ||
| 572 | before.scrollTop, | ||
| 573 | ); | ||
| 574 | }; | ||
| 575 | |||
| 576 | const openDesktopSettings = async (page: CdpPage): Promise<void> => { | ||
| 577 | const clicked = await page.evaluate<boolean>(`(() => { | ||
| 578 | const link = document.querySelector('a[href*="desktop-settings"]'); | ||
| 579 | if (!(link instanceof HTMLAnchorElement)) return false; | ||
| 580 | link.click(); | ||
| 581 | return true; | ||
| 582 | })()`); | ||
| 583 | expect(clicked, "the packaged desktop app should expose a Settings nav link").toBe(true); | ||
| 584 | await page.waitForText("Desktop server connection", 30_000); | ||
| 585 | }; | ||
| 586 | |||
| 587 | const writeStaleActiveServerProfile = (input: { | ||
| 588 | readonly home: string; | ||
| 589 | readonly port: number; | ||
| 590 | }): void => { | ||
| 591 | const staleOrigin = `http://127.0.0.1:${input.port}`; | ||
| 592 | const staleKey = `http:${staleOrigin}`; | ||
| 593 | const settings = `${JSON.stringify( | ||
| 594 | { | ||
| 595 | server: { port: input.port }, | ||
| 596 | serverProfiles: JSON.stringify({ | ||
| 597 | version: 1, | ||
| 598 | activeKey: staleKey, | ||
| 599 | profiles: [ | ||
| 600 | { | ||
| 601 | kind: "http", | ||
| 602 | origin: staleOrigin, | ||
| 603 | displayName: "Stale Basic daemon", | ||
| 604 | auth: { kind: "basic", username: "executor", password: "wrong-password" }, | ||
| 605 | }, | ||
| 606 | ], | ||
| 607 | }), | ||
| 608 | }, | ||
| 609 | null, | ||
| 610 | 2, | ||
| 611 | )}\n`; | ||
| 612 | for (const settingsDir of new Set(desktopSettingsDirs(input.home))) { | ||
| 613 | mkdirSync(settingsDir, { recursive: true }); | ||
| 614 | writeFileSync(join(settingsDir, "settings.json"), settings, { mode: 0o600 }); | ||
| 146 | } | 615 | } |
| 147 | rmSync(videoTmp, { recursive: true, force: true }); | ||
| 148 | }; | 616 | }; |
| 149 | 617 | ||
| 150 | scenario( | 618 | scenario( |
| @@ -159,16 +627,17 @@ scenario( · 8 unmodified lines | |||
| 159 | let daemon: ChildProcess | undefined; | 627 | let daemon: ChildProcess | undefined; |
| 160 | const previousUmask = process.umask(0o022); | 628 | const previousUmask = process.umask(0o022); |
| 161 | try { | 629 | try { |
| 162 | const started = await startSupervisedDaemon({ | 630 | const started = await startSupervisedDaemon( |
| 163 | ...process.env, | 631 | { |
| 164 | HOME: home, | 632 | ...process.env, |
| 165 | EXECUTOR_SUPERVISED: "1", | 633 | HOME: home, |
| 166 | EXECUTOR_DATA_DIR: dataDir, | 634 | EXECUTOR_SUPERVISED: "1", |
| 167 | EXECUTOR_PORT: String(port), | 635 | EXECUTOR_DATA_DIR: dataDir, |
| 168 | EXECUTOR_HOST: "127.0.0.1", | 636 | EXECUTOR_AUTH_TOKEN: "manifest-mode-token", |
| 169 | EXECUTOR_AUTH_TOKEN: "manifest-mode-token", | 637 | EXECUTOR_CLIENT: "desktop", |
| 170 | EXECUTOR_CLIENT_DIR: clientDir, | 638 | }, |
| 171 | }); | 639 | port, |
| 640 | ); | ||
| 172 | daemon = started.child; | 641 | daemon = started.child; |
| 173 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe( | 642 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe( |
| 174 | true, | 643 | true, |
| @@ -194,10 +663,7 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 19 unmodified lines | |||
| 194 | scenario( | 663 | scenario( |
| 195 | "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", | 664 | "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", |
| 196 | { timeout: 240_000 }, | 665 | { timeout: 240_000 }, |
| 197 | Effect.gen(function* () { | 666 | Effect.promise(() => runStaleManifestProbe()), |
| 198 | const runDir = yield* RunDir; | ||
| 199 | yield* Effect.promise(() => runStaleManifestProbe(runDir)); | ||
| 200 | }), | ||
| 201 | ); | 667 | ); |
| 202 | 668 | ||
| 203 | scenario( | 669 | scenario( |
| @@ -208,24 +674,24 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 4 unmodified lines | |||
| 208 | yield* Effect.promise(() => runSupervisedPortSetting(runDir)); | 674 | yield* Effect.promise(() => runSupervisedPortSetting(runDir)); |
| 209 | }), | 675 | }), |
| 210 | ); | 676 | ); |
| 211 | } | ||
| 212 | 677 | ||
| 213 | const launchPackaged = (home: string, videoTmp: string): Promise<ElectronApplication> => { | 678 | scenario( |
| 214 | const { app } = requireBundle(); | 679 | "Desktop packaged supervised attach · integrations load through the CLI daemon with stale profiles", |
| 215 | return _electron.launch({ | 680 | { timeout: 240_000 }, |
| 216 | executablePath: app, | 681 | Effect.gen(function* () { |
| 217 | env: { ...process.env, HOME: home }, | 682 | const runDir = yield* RunDir; |
| 218 | recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, | 683 | yield* Effect.promise(() => runSupervisedIntegrationsLoad(runDir)); |
| 219 | timeout: 120_000, | 684 | }), |
| 220 | }); | 685 | ); |
| 221 | }; | 686 | } |
| 222 | 687 | ||
| 223 | const runStaleManifestProbe = async (runDir: string) => { | 688 | const runStaleManifestProbe = async () => { |
| 224 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); | 689 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); |
| 225 | const dataDir = join(home, ".executor"); | 690 | const dataDir = join(home, ".executor"); |
| 226 | const controlDir = join(dataDir, "server-control"); | 691 | const controlDir = join(dataDir, "server-control"); |
| 227 | const videoTmp = join(runDir, ".video-tmp"); | 692 | const manifestPath = join(controlDir, "server.json"); |
| 228 | const token = "stale-manifest-leaked-token"; | 693 | const token = "stale-manifest-leaked-token"; |
| 694 | const launchdSnapshot = captureLaunchdService(); | ||
| 229 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; | 695 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; |
| 230 | let resolveFirst!: () => void; | 696 | let resolveFirst!: () => void; |
| 231 | const firstRequest = new Promise<void>((resolve) => { | 697 | const firstRequest = new Promise<void>((resolve) => { |
| @@ -238,11 +704,12 @@ const runStaleManifestProbe = async (runDir: string) => { · 6 unmodified lines | |||
| 238 | }); | 704 | }); |
| 239 | resolveFirst(); | 705 | resolveFirst(); |
| 240 | res.writeHead(200, { "content-type": "text/html" }); | 706 | res.writeHead(200, { "content-type": "text/html" }); |
| 241 | res.end("<!doctype html><title>fake daemon</title><body>fake daemon</body>"); | 707 | res.end("<!doctype html><title>stale daemon</title><body>stale daemon</body>"); |
| 242 | }); | 708 | }); |
| 243 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | 709 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 244 | const port = (server.address() as net.AddressInfo).port; | 710 | const port = (server.address() as net.AddressInfo).port; |
| 245 | let app: ElectronApplication | undefined; | 711 | let appProcess: ChildProcess | undefined; |
| 712 | let appOutput = ""; | ||
| 246 | 713 | ||
| 247 | try { | 714 | try { |
| 248 | mkdirSync(controlDir, { recursive: true }); | 715 | mkdirSync(controlDir, { recursive: true }); |
| @@ -265,19 +732,35 @@ const runStaleManifestProbe = async (runDir: string) => { · 16 unmodified lines | |||
| 265 | { mode: 0o600 }, | 732 | { mode: 0o600 }, |
| 266 | ); | 733 | ); |
| 267 | 734 | ||
| 268 | app = await launchPackaged(home, videoTmp); | 735 | appProcess = spawn(requireBundle().app, [], { |
| 736 | env: packagedAppEnv(home), | ||
| 737 | stdio: ["ignore", "pipe", "pipe"], | ||
| 738 | }); | ||
| 739 | const collectOutput = (chunk: Buffer) => { | ||
| 740 | appOutput = (appOutput + chunk.toString()).slice(-8_192); | ||
| 741 | }; | ||
| 742 | appProcess.stdout?.on("data", collectOutput); | ||
| 743 | appProcess.stderr?.on("data", collectOutput); | ||
| 744 | |||
| 269 | const probed = await Promise.race([ | 745 | const probed = await Promise.race([ |
| 270 | firstRequest.then(() => true), | 746 | firstRequest.then(() => true), |
| 271 | new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)), | 747 | new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)), |
| 272 | ]); | 748 | ]); |
| 273 | expect(probed, "packaged app probed the stale manifest endpoint").toBe(true); | 749 | expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true); |
| 274 | 750 | ||
| 275 | expect( | 751 | expect( |
| 276 | requests[0]?.authorization ?? null, | 752 | requests[0]?.authorization ?? null, |
| 277 | "the stale-manifest reachability probe must not disclose the saved bearer", | 753 | "the stale-manifest reachability probe must not disclose the saved bearer", |
| 278 | ).toBeNull(); | 754 | ).toBeNull(); |
| 755 | |||
| 756 | const manifestRemoved = await waitUntil(() => !existsSync(manifestPath), 15_000); | ||
| 757 | expect( | ||
| 758 | manifestRemoved, | ||
| 759 | "a live pid with a failed health probe must be removed before desktop falls back", | ||
| 760 | ).toBe(true); | ||
| 279 | } finally { | 761 | } finally { |
| 280 | await closeWithVideo(app, runDir, videoTmp); | 762 | await stopProcess(appProcess); |
| 763 | await restoreLaunchdService(launchdSnapshot); | ||
| 281 | await new Promise<void>((resolve) => server.close(() => resolve())); | 764 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 282 | rmSync(home, { recursive: true, force: true }); | 765 | rmSync(home, { recursive: true, force: true }); |
| 283 | } | 766 | } |
| @@ -286,64 +769,166 @@ const runStaleManifestProbe = async (runDir: string) => { · 2 unmodified lines | |||
| 286 | const runSupervisedPortSetting = async (runDir: string) => { | 769 | const runSupervisedPortSetting = async (runDir: string) => { |
| 287 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-")); | 770 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-")); |
| 288 | const dataDir = join(home, ".executor"); | 771 | const dataDir = join(home, ".executor"); |
| 289 | const videoTmp = join(runDir, ".video-tmp"); | 772 | const launchdSnapshot = captureLaunchdService(); |
| 290 | const oldPort = await freePort(); | 773 | const oldPort = await freePort(); |
| 291 | const newPort = await freePort(); | 774 | const newPort = await freePort(); |
| 292 | let daemon: ChildProcess | undefined; | 775 | let daemon: ChildProcess | undefined; |
| 293 | let app: ElectronApplication | undefined; | 776 | let app: PackagedApp | undefined; |
| 294 | 777 | ||
| 295 | try { | 778 | try { |
| 296 | const started = await startSupervisedDaemon({ | 779 | const started = await startSupervisedDaemon( |
| 297 | ...process.env, | 780 | { |
| 298 | HOME: home, | 781 | ...process.env, |
| 299 | EXECUTOR_SUPERVISED: "1", | 782 | HOME: home, |
| 300 | EXECUTOR_DATA_DIR: dataDir, | 783 | EXECUTOR_SUPERVISED: "1", |
| 301 | EXECUTOR_PORT: String(oldPort), | 784 | EXECUTOR_DATA_DIR: dataDir, |
| 302 | EXECUTOR_HOST: "127.0.0.1", | 785 | EXECUTOR_AUTH_TOKEN: "port-setting-token", |
| 303 | EXECUTOR_AUTH_TOKEN: "port-setting-token", | 786 | EXECUTOR_CLIENT: "desktop", |
| 304 | EXECUTOR_CLIENT_DIR: clientDir, | 787 | }, |
| 305 | }); | 788 | oldPort, |
| 789 | ); | ||
| 306 | daemon = started.child; | 790 | daemon = started.child; |
| 307 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); | 791 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); |
| 308 | await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 }); | 792 | await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 }); |
| 309 | 793 | ||
| 310 | app = await launchPackaged(home, videoTmp); | 794 | app = await launchPackaged(home); |
| 311 | const page = await app.firstWindow({ timeout: 120_000 }); | 795 | let page = app.cdp; |
| 312 | await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); | 796 | await page.waitForText("Settings", 120_000); |
| 797 | await openDesktopSettings(page); | ||
| 798 | await assertDesktopSettingsScrolls(page); | ||
| 799 | await page.screenshot(join(runDir, "01-attached-settings.png")); | ||
| 313 | 800 | ||
| 314 | const before = await page.evaluate(async () => { | 801 | const before = await page.evaluate<{ readonly origin: string } | null>( |
| 315 | return window.executor.getServerConnection(); | 802 | "window.executor.getServerConnection()", |
| 316 | }); | 803 | ); |
| 317 | expect(new URL(before!.origin).port, "test starts attached to the original port").toBe( | 804 | expect(new URL(before!.origin).port, "test starts attached to the original port").toBe( |
| 318 | String(oldPort), | 805 | String(oldPort), |
| 319 | ); | 806 | ); |
| 320 | 807 | ||
| 321 | await page.evaluate(async (port) => { | 808 | await page.evaluate(`window.executor.updateSettings({ port: ${JSON.stringify(newPort)} })`); |
| 322 | await window.executor.updateSettings({ port }); | ||
| 323 | }, newPort); | ||
| 324 | 809 | ||
| 325 | await page | 810 | await page |
| 326 | .evaluate(async () => { | 811 | .evaluate("window.executor.restartServer().catch(() => undefined)") |
| 327 | await window.executor.restartServer(); | ||
| 328 | }) | ||
| 329 | .catch(() => undefined); | 812 | .catch(() => undefined); |
| 330 | await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); | 813 | page = await reconnectPackagedPage(app); |
| 814 | await page.waitForText("Settings", 120_000); | ||
| 331 | 815 | ||
| 332 | const after = await page.evaluate(async () => { | 816 | const after = await page.evaluate<{ |
| 333 | return { | 817 | readonly settings: { readonly port: number }; |
| 334 | settings: await window.executor.getSettings(), | 818 | readonly connection: { readonly origin: string } | null; |
| 335 | connection: await window.executor.getServerConnection(), | 819 | }>( |
| 336 | }; | 820 | "(async () => ({ settings: await window.executor.getSettings(), connection: await window.executor.getServerConnection() }))()", |
| 337 | }); | 821 | ); |
| 338 | 822 | ||
| 339 | expect(after.settings.port, "the setting was persisted").toBe(newPort); | 823 | expect(after.settings.port, "the setting was persisted").toBe(newPort); |
| 340 | expect( | 824 | expect( |
| 341 | new URL(after.connection!.origin).port, | 825 | new URL(after.connection!.origin).port, |
| 342 | "after restart, the active supervised daemon should be serving on the saved port", | 826 | "after restart, the active supervised daemon should be serving on the saved port", |
| 343 | ).toBe(String(newPort)); | 827 | ).toBe(String(newPort)); |
| 828 | await page.screenshot(join(runDir, "02-restarted-on-new-port.png")); | ||
| 829 | } finally { | ||
| 830 | await closePackaged(app); | ||
| 831 | await stopProcess(daemon); | ||
| 832 | await restoreLaunchdService(launchdSnapshot); | ||
| 833 | rmSync(home, { recursive: true, force: true }); | ||
| 834 | } | ||
| 835 | }; | ||
| 836 | |||
| 837 | const runSupervisedIntegrationsLoad = async (runDir: string) => { | ||
| 838 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-integrations-load-")); | ||
| 839 | const dataDir = join(home, ".executor"); | ||
| 840 | const launchdSnapshot = captureLaunchdService(); | ||
| 841 | const port = await freePort(); | ||
| 842 | let daemon: ChildProcess | undefined; | ||
| 843 | let app: PackagedApp | undefined; | ||
| 844 | |||
| 845 | try { | ||
| 846 | writeStaleActiveServerProfile({ home, port }); | ||
| 847 | const started = await startSupervisedDaemon( | ||
| 848 | { | ||
| 849 | ...process.env, | ||
| 850 | HOME: home, | ||
| 851 | EXECUTOR_SUPERVISED: "1", | ||
| 852 | EXECUTOR_DATA_DIR: dataDir, | ||
| 853 | EXECUTOR_AUTH_TOKEN: "integrations-load-token", | ||
| 854 | EXECUTOR_CLIENT: "desktop", | ||
| 855 | }, | ||
| 856 | port, | ||
| 857 | "localhost", | ||
| 858 | ); | ||
| 859 | daemon = started.child; | ||
| 860 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); | ||
| 861 | await waitForHttp(`http://localhost:${port}/`, { timeoutMs: 30_000 }); | ||
| 862 | |||
| 863 | const rootDocument = await fetch(`http://localhost:${port}/`); | ||
| 864 | expect( | ||
| 865 | rootDocument.headers.get("cache-control"), | ||
| 866 | "SPA boot document should not be cached", | ||
| 867 | ).toBe("no-store"); | ||
| 868 | await rootDocument.body?.cancel(); | ||
| 869 | const indexDocument = await fetch(`http://localhost:${port}/index.html`); | ||
| 870 | expect( | ||
| 871 | indexDocument.headers.get("cache-control"), | ||
| 872 | "direct index.html requests should not cache the SPA boot document", | ||
| 873 | ).toBe("no-store"); | ||
| 874 | await indexDocument.body?.cancel(); | ||
| 875 | |||
| 876 | app = await launchPackaged(home); | ||
| 877 | const page = app.cdp; | ||
| 878 | |||
| 879 | const serverLabel = await waitForServerConnectionLabel(page, "Local Executor", 120_000); | ||
| 880 | expect(serverLabel, "desktop must not auto-select a stale persisted server profile").toContain( | ||
| 881 | "Local Executor", | ||
| 882 | ); | ||
| 883 | await page.waitForExpression( | ||
| 884 | `document.querySelector('a[href$="/integrations/executor"]') !== null`, | ||
| 885 | 120_000, | ||
| 886 | "the built-in Executor integration link", | ||
| 887 | ); | ||
| 888 | const bootstrap = await page.evaluate<{ | ||
| 889 | readonly href: string; | ||
| 890 | readonly navigationName: string; | ||
| 891 | }>( | ||
| 892 | `(() => { | ||
| 893 | const navigation = performance.getEntriesByType("navigation")[0]; | ||
| 894 | return { | ||
| 895 | href: location.href, | ||
| 896 | navigationName: navigation?.name ?? "", | ||
| 897 | }; | ||
| 898 | })()`, | ||
| 899 | ); | ||
| 900 | expect( | ||
| 901 | bootstrap.navigationName, | ||
| 902 | "desktop should cache-bust each packaged renderer document load", | ||
| 903 | ).toContain("_executor_desktop_launch="); | ||
| 904 | expect( | ||
| 905 | bootstrap.navigationName, | ||
| 906 | "desktop should pass the daemon token during bootstrap", | ||
| 907 | ).toContain("_token="); | ||
| 908 | expect( | ||
| 909 | bootstrap.href, | ||
| 910 | "desktop should strip bootstrap cache-bust params after load", | ||
| 911 | ).not.toContain("_executor_desktop_launch="); | ||
| 912 | expect(bootstrap.href, "desktop should strip bootstrap token params after load").not.toContain( | ||
| 913 | "_token=", | ||
| 914 | ); | ||
| 915 | await page.screenshot(join(runDir, "01-integrations-loaded.png")); | ||
| 916 | expect( | ||
| 917 | await page.textPresent("Failed to load integrations").then((present) => (present ? 1 : 0)), | ||
| 918 | "integrations should render from the attached daemon, not a cached 401/500 failure", | ||
| 919 | ).toBe(0); | ||
| 920 | |||
| 921 | const connection = await page.evaluate<{ readonly origin: string } | null>( | ||
| 922 | "window.executor.getServerConnection()", | ||
| 923 | ); | ||
| 924 | expect( | ||
| 925 | new URL(connection!.origin).port, | ||
| 926 | "the packaged app is rendering data from the supervised daemon", | ||
| 927 | ).toBe(String(port)); | ||
| 344 | } finally { | 928 | } finally { |
| 345 | await closeWithVideo(app, runDir, videoTmp); | 929 | await closePackaged(app); |
| 346 | daemon?.kill("SIGTERM"); | 930 | await stopProcess(daemon); |
| 931 | await restoreLaunchdService(launchdSnapshot); | ||
| 347 | rmSync(home, { recursive: true, force: true }); | 932 | rmSync(home, { recursive: true, force: true }); |
| 348 | } | 933 | } |
| 349 | }; | 934 | }; |
| @@ -2,12 +2,12 @@ · 1 unmodified line | |||
| 2 | // dev electron) so the scenarios drive the production artifact — the only place | 2 | // dev electron) so the scenarios drive the production artifact — the only place |
| 3 | // app.isPackaged is true, which is what gates the supervised-daemon attach path | 3 | // app.isPackaged is true, which is what gates the supervised-daemon attach path |
| 4 | // (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled | 4 | // (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled |
| 5 | // compiled sidecar (executor-sidecar + extraResources). The dev-electron desktop | 5 | // bundled executor CLI binary. The dev-electron desktop |
| 6 | // project can't reach any of that. | 6 | // project can't reach any of that. |
| 7 | // | 7 | // |
| 8 | // Builds web UI → compiled sidecar → electron-vite main/preload → electron-builder | 8 | // Builds bundled executor → electron-vite main/preload → electron-builder |
| 9 | // (unsigned e2e config, `dir` target = the unpacked .app/.exe, no DMG/notarize). | 9 | // (unsigned e2e config, `dir` target = the unpacked .app/.exe, no DMG/notarize). |
| 10 | // Publishes the launch exe + the bundled sidecar path via env for the workers. | 10 | // Publishes the launch exe + the bundled executor path via env for the workers. |
| 11 | // | 11 | // |
| 12 | // Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to | 12 | // Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to |
| 13 | // reuse an existing dist/ bundle while iterating. | 13 | // reuse an existing dist/ bundle while iterating. |
| @@ -16,24 +16,23 @@ import { existsSync } from "node:fs"; · 2 unmodified lines | |||
| 16 | import { fileURLToPath } from "node:url"; | 16 | import { fileURLToPath } from "node:url"; |
| 17 | import { join } from "node:path"; | 17 | import { join } from "node:path"; |
| 18 | 18 | ||
| 19 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); | ||
| 20 | const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); | 19 | const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); |
| 21 | 20 | ||
| 22 | // (launch exe, bundled sidecar binary) inside the packaged bundle, per platform. | 21 | // (launch exe, bundled executor binary) inside the packaged bundle, per platform. |
| 23 | const bundlePaths = (): { exe: string; sidecar: string } => { | 22 | const bundlePaths = (): { exe: string; executor: string } => { |
| 24 | const arch = process.arch; // arm64 | x64 | 23 | const arch = process.arch; // arm64 | x64 |
| 25 | if (process.platform === "darwin") { | 24 | if (process.platform === "darwin") { |
| 26 | const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app"); | 25 | const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app"); |
| 27 | return { | 26 | return { |
| 28 | exe: join(app, "Contents/MacOS/Executor"), | 27 | exe: join(app, "Contents/MacOS/Executor"), |
| 29 | sidecar: join(app, "Contents/Resources/sidecar/executor-sidecar"), | 28 | executor: join(app, "Contents/Resources/executor/executor"), |
| 30 | }; | 29 | }; |
| 31 | } | 30 | } |
| 32 | if (process.platform === "win32") { | 31 | if (process.platform === "win32") { |
| 33 | const dir = join(appDir, "dist", "win-unpacked"); | 32 | const dir = join(appDir, "dist", "win-unpacked"); |
| 34 | return { | 33 | return { |
| 35 | exe: join(dir, "Executor.exe"), | 34 | exe: join(dir, "Executor.exe"), |
| 36 | sidecar: join(dir, "resources/sidecar/executor-sidecar.exe"), | 35 | executor: join(dir, "resources/executor/executor.exe"), |
| 37 | }; | 36 | }; |
| 38 | } | 37 | } |
| 39 | // electron-builder names the dir `linux-unpacked` for x64 and | 38 | // electron-builder names the dir `linux-unpacked` for x64 and |
| @@ -41,7 +40,7 @@ const bundlePaths = (): { exe: string; sidecar: string } => { · 1 unmodified line | |||
| 41 | const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`); | 40 | const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`); |
| 42 | return { | 41 | return { |
| 43 | exe: join(dir, "executor-desktop"), | 42 | exe: join(dir, "executor-desktop"), |
| 44 | sidecar: join(dir, "resources/sidecar/executor-sidecar"), | 43 | executor: join(dir, "resources/executor/executor"), |
| 45 | }; | 44 | }; |
| 46 | }; | 45 | }; |
| 47 | 46 | ||
| @@ -49,18 +48,16 @@ const builderFlag = · 1 unmodified line | |||
| 49 | process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux"; | 48 | process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux"; |
| 50 | 49 | ||
| 51 | export default function setup() { | 50 | export default function setup() { |
| 52 | const { exe, sidecar } = bundlePaths(); | 51 | const { exe, executor } = bundlePaths(); |
| 53 | 52 | ||
| 54 | if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) { | 53 | if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) { |
| 55 | const run = (cmd: string, args: string[], cwd: string) => | 54 | const run = (cmd: string, args: string[], cwd: string) => |
| 56 | execFileSync(cmd, args, { cwd, stdio: "inherit", env: { ...process.env } }); | 55 | execFileSync(cmd, args, { cwd, stdio: "inherit", env: { ...process.env } }); |
| 57 | // 1. web UI bundle (served by the sidecar; staged into the package). | 56 | // 1. compiled CLI + embedded web UI + native bindings → resources/executor. |
| 58 | run("bun", ["run", "--filter", "@executor-js/local", "build"], repoRoot); | ||
| 59 | // 2. compiled sidecar + native bindings → resources/sidecar. | ||
| 60 | run("bun", ["./scripts/build-sidecar.ts"], appDir); | 57 | run("bun", ["./scripts/build-sidecar.ts"], appDir); |
| 61 | // 3. electron-vite main/preload → out/. | 58 | // 2. electron-vite main/preload → out/. |
| 62 | run("bunx", ["--bun", "electron-vite", "build"], appDir); | 59 | run("bunx", ["--bun", "electron-vite", "build"], appDir); |
| 63 | // 4. electron-builder unsigned bundle (dir target). CSC_IDENTITY_AUTO_DISCOVERY | 60 | // 3. electron-builder unsigned bundle (dir target). CSC_IDENTITY_AUTO_DISCOVERY |
| 64 | // off so it never reaches for a signing identity. | 61 | // off so it never reaches for a signing identity. |
| 65 | execFileSync( | 62 | execFileSync( |
| 66 | "bunx", | 63 | "bunx", |
| @@ -76,9 +73,9 @@ export default function setup() { · 9 unmodified lines | |||
| 76 | if (!existsSync(exe)) { | 73 | if (!existsSync(exe)) { |
| 77 | throw new Error(`packaged desktop exe not found at ${exe} after build`); | 74 | throw new Error(`packaged desktop exe not found at ${exe} after build`); |
| 78 | } | 75 | } |
| 79 | if (!existsSync(sidecar)) { | 76 | if (!existsSync(executor)) { |
| 80 | throw new Error(`bundled sidecar not found at ${sidecar} after build`); | 77 | throw new Error(`bundled executor not found at ${executor} after build`); |
| 81 | } | 78 | } |
| 82 | process.env.E2E_DESKTOP_APP_EXE = exe; | 79 | process.env.E2E_DESKTOP_APP_EXE = exe; |
| 83 | process.env.E2E_DESKTOP_SIDECAR_BIN = sidecar; | 80 | process.env.E2E_DESKTOP_EXECUTOR_BIN = executor; |
| 84 | } | 81 | } |
| @@ -58,8 +58,8 @@ export default defineConfig({ · 57 unmodified lines | |||
| 58 | }), | 58 | }), |
| 59 | // The PACKAGED desktop app: the real electron-builder bundle, where | 59 | // The PACKAGED desktop app: the real electron-builder bundle, where |
| 60 | // app.isPackaged is true — the ONLY target that exercises the supervised- | 60 | // app.isPackaged is true — the ONLY target that exercises the supervised- |
| 61 | // daemon attach path (ensureSupervisedConnection) and the bundled compiled | 61 | // daemon attach path (ensureSupervisedConnection) and the bundled executor. |
| 62 | // sidecar. Its globalsetup builds the bundle (slow), so it's separate from | 62 | // Its globalsetup builds the bundle (slow), so it's separate from |
| 63 | // `desktop` to keep the fast dev-electron suite off the package build. | 63 | // `desktop` to keep the fast dev-electron suite off the package build. |
| 64 | // Needs a display; not part of the default `npm run test` chain — run with | 64 | // Needs a display; not part of the default `npm run test` chain — run with |
| 65 | // `vitest run --project desktop-packaged`. | 65 | // `vitest run --project desktop-packaged`. |
| @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; · 1 unmodified line | |||
| 2 | import { ServerIcon } from "lucide-react"; | 2 | import { ServerIcon } from "lucide-react"; |
| 3 | import { | 3 | import { |
| 4 | getExecutorServerAuthorizationHeader, | 4 | getExecutorServerAuthorizationHeader, |
| 5 | normalizeExecutorServerConnection, | ||
| 5 | useExecutorServerConnection, | 6 | useExecutorServerConnection, |
| 6 | useSetExecutorServerConnection, | 7 | useSetExecutorServerConnection, |
| 7 | type ExecutorServerAuth, | 8 | type ExecutorServerAuth, |
| @@ -74,6 +75,16 @@ const desktopProfileStorageBridge = (): DesktopProfileStorageBridge | null => { · 66 unmodified lines | |||
| 74 | }; | 75 | }; |
| 75 | }; | 76 | }; |
| 76 | 77 | ||
| 78 | const hasDesktopServerConnectionBridge = (): boolean => | ||
| 79 | Boolean(desktopProfileStorageBridge()) || | ||
| 80 | typeof globalThis.window?.executor?.getServerConnection === "function"; | ||
| 81 | |||
| 82 | const readDesktopServerConnection = (): Promise<ExecutorServerConnectionInput | null> | null => { | ||
| 83 | const bridge = globalThis.window?.executor; | ||
| 84 | if (!bridge || typeof bridge.getServerConnection !== "function") return null; | ||
| 85 | return bridge.getServerConnection(); | ||
| 86 | }; | ||
| 87 | |||
| 77 | const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => | 88 | const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => |
| 78 | readExecutorServerProfiles(browserStorage()); | 89 | readExecutorServerProfiles(browserStorage()); |
| 79 | 90 | ||
| @@ -134,7 +145,7 @@ const serverDescription = (connection: ExecutorServerConnection): string => · 54 unmodified lines | |||
| 134 | connection.origin.replace(/^https?:\/\//, ""); | 145 | connection.origin.replace(/^https?:\/\//, ""); |
| 135 | 146 | ||
| 136 | const serverKindLabel = (connection: ExecutorServerConnection): string => { | 147 | const serverKindLabel = (connection: ExecutorServerConnection): string => { |
| 137 | if (connection.kind === "desktop-sidecar") return "Desktop"; | 148 | if (connection.kind === "desktop-sidecar") return "Local"; |
| 138 | const hostname = new URL(connection.origin).hostname; | 149 | const hostname = new URL(connection.origin).hostname; |
| 139 | return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" | 150 | return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" |
| 140 | ? "Local" | 151 | ? "Local" |
| @@ -149,6 +160,29 @@ const authLabel = (connection: ExecutorServerConnection): string => { · 8 unmodified lines | |||
| 149 | return "Auth"; | 160 | return "Auth"; |
| 150 | }; | 161 | }; |
| 151 | 162 | ||
| 163 | const isLoopbackHost = (host: string): boolean => | ||
| 164 | host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; | ||
| 165 | |||
| 166 | const isLoopbackConnection = (connection: ExecutorServerConnection): boolean => { | ||
| 167 | const url = new URL(connection.origin); | ||
| 168 | return isLoopbackHost(url.hostname); | ||
| 169 | }; | ||
| 170 | |||
| 171 | const sameLoopbackServer = (a: ExecutorServerConnection, b: ExecutorServerConnection): boolean => { | ||
| 172 | const left = new URL(a.origin); | ||
| 173 | const right = new URL(b.origin); | ||
| 174 | return ( | ||
| 175 | left.protocol === right.protocol && | ||
| 176 | isLoopbackHost(left.hostname) && | ||
| 177 | isLoopbackHost(right.hostname) && | ||
| 178 | (left.port || (left.protocol === "https:" ? "443" : "80")) === | ||
| 179 | (right.port || (right.protocol === "https:" ? "443" : "80")) | ||
| 180 | ); | ||
| 181 | }; | ||
| 182 | |||
| 183 | const hasBearerAuth = (connection: ExecutorServerConnection): boolean => | ||
| 184 | connection.auth?.kind === "bearer" && connection.auth.token.length > 0; | ||
| 185 | |||
| 152 | const snapshotWithCurrent = ( | 186 | const snapshotWithCurrent = ( |
| 153 | snapshot: ExecutorServerProfilesSnapshot, | 187 | snapshot: ExecutorServerProfilesSnapshot, |
| 154 | connection: ExecutorServerConnection, | 188 | connection: ExecutorServerConnection, |
| @@ -156,6 +190,14 @@ const snapshotWithCurrent = ( · 1 unmodified line | |||
| 156 | ): ExecutorServerProfilesSnapshot => | 190 | ): ExecutorServerProfilesSnapshot => |
| 157 | upsertExecutorServerProfile(snapshot, connection, { makeActive }) ?? snapshot; | 191 | upsertExecutorServerProfile(snapshot, connection, { makeActive }) ?? snapshot; |
| 158 | 192 | ||
| 193 | const withoutLoopbackProfiles = ( | ||
| 194 | snapshot: ExecutorServerProfilesSnapshot, | ||
| 195 | ): ExecutorServerProfilesSnapshot => | ||
| 196 | normalizeExecutorServerProfilesSnapshot({ | ||
| 197 | activeKey: snapshot.activeKey, | ||
| 198 | profiles: snapshot.profiles.filter((profile) => !isLoopbackConnection(profile)), | ||
| 199 | }); | ||
| 200 | |||
| 159 | const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => { | 201 | const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => { |
| 160 | const secret = draft.secret.trim(); | 202 | const secret = draft.secret.trim(); |
| 161 | if (draft.authMode === "bearer" && secret) { | 203 | if (draft.authMode === "bearer" && secret) { |
| @@ -201,14 +243,34 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 39 unmodified lines | |||
| 201 | 243 | ||
| 202 | let cancelled = false; | 244 | let cancelled = false; |
| 203 | void readStoredProfiles().then((stored) => { | 245 | void readStoredProfiles().then((stored) => { |
| 204 | if (cancelled) return; | 246 | void (async () => { |
| 205 | const storedActive = getActiveExecutorServerProfile(stored); | 247 | if (cancelled) return; |
| 206 | const next = snapshotWithCurrent(stored, connection, storedActive === null); | 248 | const desktopBridge = hasDesktopServerConnectionBridge(); |
| 207 | persistSnapshot(next); | 249 | const desktopConnection = |
| 208 | if (storedActive && storedActive.key !== connection.key) { | 250 | (await readDesktopServerConnection()?.then( |
| 209 | setServerConnection(storedActive); | 251 | (value) => value, |
| 210 | } | 252 | () => null, |
| 211 | setHydrated(true); | 253 | )) ?? null; |
| 254 | const storedActive = getActiveExecutorServerProfile(stored); | ||
| 255 | const current = desktopConnection | ||
| 256 | ? normalizeExecutorServerConnection(desktopConnection) | ||
| 257 | : connection; | ||
| 258 | const baseStored = desktopConnection ? withoutLoopbackProfiles(stored) : stored; | ||
| 259 | const shouldKeepCurrent = | ||
| 260 | desktopBridge || | ||
| 261 | storedActive === null || | ||
| 262 | (hasBearerAuth(current) && | ||
| 263 | storedActive !== null && | ||
| 264 | sameLoopbackServer(current, storedActive)); | ||
| 265 | const next = snapshotWithCurrent(baseStored, current, shouldKeepCurrent); | ||
| 266 | persistSnapshot(next); | ||
| 267 | if (desktopConnection) { | ||
| 268 | setServerConnection(desktopConnection); | ||
| 269 | } else if (!shouldKeepCurrent && storedActive && storedActive.key !== connection.key) { | ||
| 270 | setServerConnection(storedActive); | ||
| 271 | } | ||
| 272 | setHydrated(true); | ||
| 273 | })(); | ||
| 212 | }); | 274 | }); |
| 213 | 275 | ||
| 214 | return () => { | 276 | return () => { |
| @@ -219,7 +281,10 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 4 unmodified lines | |||
| 219 | useEffect(() => { | 281 | useEffect(() => { |
| 220 | if (!hydrated) return; | 282 | if (!hydrated) return; |
| 221 | setSnapshot((previous) => { | 283 | setSnapshot((previous) => { |
| 222 | const next = snapshotWithCurrent(previous, connection, true); | 284 | const base = hasDesktopServerConnectionBridge() |
| 285 | ? withoutLoopbackProfiles(previous) | ||
| 286 | : previous; | ||
| 287 | const next = snapshotWithCurrent(base, connection, true); | ||
| 223 | writeStoredProfiles(next); | 288 | writeStoredProfiles(next); |
| 224 | return next; | 289 | return next; |
| 225 | }); | 290 | }); |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | |||
| 13 | * module-init time — so the web UI doesn't show a non-functional link. | 13 | * module-init time — so the web UI doesn't show a non-functional link. |
| 14 | */ | 14 | */ |
| 15 | 15 | ||
| 16 | import { useCallback, useEffect, useState } from "react"; | 16 | import { type CSSProperties, useCallback, useEffect, useState } from "react"; |
| 17 | import { defineClientPlugin } from "@executor-js/sdk/client"; | 17 | import { defineClientPlugin } from "@executor-js/sdk/client"; |
| 18 | 18 | ||
| 19 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| @@ -72,6 +72,12 @@ const inDesktop = readBridge() !== null; · 52 unmodified lines | |||
| 72 | const describeIpcError = (_err: unknown): string => | 72 | const describeIpcError = (_err: unknown): string => |
| 73 | "Save failed — check the desktop console for details."; | 73 | "Save failed — check the desktop console for details."; |
| 74 | 74 | ||
| 75 | const pageFrameStyle: CSSProperties = { | ||
| 76 | minHeight: 0, | ||
| 77 | height: "100%", | ||
| 78 | overflowY: "auto", | ||
| 79 | }; | ||
| 80 | |||
| 75 | // --------------------------------------------------------------------------- | 81 | // --------------------------------------------------------------------------- |
| 76 | // SettingsPage | 82 | // SettingsPage |
| 77 | // --------------------------------------------------------------------------- | 83 | // --------------------------------------------------------------------------- |
| @@ -168,17 +174,23 @@ function SettingsPage() { · 90 unmodified lines | |||
| 168 | 174 | ||
| 169 | if (!bridge) { | 175 | if (!bridge) { |
| 170 | return ( | 176 | return ( |
| 171 | <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}> | 177 | <div style={pageFrameStyle}> |
| 172 | <h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>Desktop server settings</h1> | 178 | <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}> |
| 173 | <p style={{ color: "var(--muted-foreground, #888)" }}> | 179 | <h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>Desktop server settings</h1> |
| 174 | Open this page from Executor Desktop to inspect and change the active server connection. | 180 | <p style={{ color: "var(--muted-foreground, #888)" }}> |
| 175 | </p> | 181 | Open this page from Executor Desktop to inspect and change the active server connection. |
| 182 | </p> | ||
| 183 | </div> | ||
| 176 | </div> | 184 | </div> |
| 177 | ); | 185 | ); |
| 178 | } | 186 | } |
| 179 | 187 | ||
| 180 | if (!settings || !draft || !connection) { | 188 | if (!settings || !draft || !connection) { |
| 181 | return <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}>Loading…</div>; | 189 | return ( |
| 190 | <div style={pageFrameStyle}> | ||
| 191 | <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}>Loading…</div> | ||
| 192 | </div> | ||
| 193 | ); | ||
| 182 | } | 194 | } |
| 183 | 195 | ||
| 184 | const dirty = draft.port !== settings.port; | 196 | const dirty = draft.port !== settings.port; |
| @@ -190,172 +202,102 @@ function SettingsPage() { · 5 unmodified lines | |||
| 190 | : "executor tools sources --server desktop"; | 202 | : "executor tools sources --server desktop"; |
| 191 | 203 | ||
| 192 | return ( | 204 | return ( |
| 193 | <div style={{ maxWidth: 760, margin: "2rem auto", padding: "1.5rem" }}> | 205 | <div style={pageFrameStyle}> |
| 194 | <h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.25rem" }}> | 206 | <div style={{ maxWidth: 760, margin: "2rem auto", padding: "1.5rem" }}> |
| 195 | Desktop server connection | 207 | <h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.25rem" }}> |
| 196 | </h1> | 208 | Desktop server connection |
| 197 | <p | 209 | </h1> |
| 198 | style={{ | 210 | <p |
| 199 | fontSize: "0.875rem", | 211 | style={{ |
| 200 | color: "var(--muted-foreground, #888)", | 212 | fontSize: "0.875rem", |
| 201 | marginBottom: "1.5rem", | 213 | color: "var(--muted-foreground, #888)", |
| 202 | }} | 214 | marginBottom: "1.5rem", |
| 203 | > | 215 | }} |
| 204 | {connection.displayName} | 216 | > |
| 205 | </p> | 217 | {connection.displayName} |
| 206 | 218 | </p> | |
| 207 | <section | ||
| 208 | style={{ | ||
| 209 | display: "grid", | ||
| 210 | gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))", | ||
| 211 | gap: "0.75rem", | ||
| 212 | marginBottom: "1.5rem", | ||
| 213 | }} | ||
| 214 | > | ||
| 215 | <ConnectionField label="Origin" value={connection.origin} /> | ||
| 216 | <ConnectionField label="API" value={connection.apiBaseUrl} /> | ||
| 217 | <ConnectionField label="Auth" value={authLabel} /> | ||
| 218 | <ConnectionField label="Kind" value={connection.kind} /> | ||
| 219 | </section> | ||
| 220 | 219 | ||
| 221 | <section | 220 | <section |
| 222 | style={{ | 221 | style={{ |
| 223 | display: "flex", | 222 | display: "grid", |
| 224 | flexDirection: "column", | 223 | gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))", |
| 225 | gap: "0.5rem", | 224 | gap: "0.75rem", |
| 226 | marginBottom: "1.5rem", | 225 | marginBottom: "1.5rem", |
| 227 | padding: "0.85rem", | 226 | }} |
| 228 | borderRadius: 6, | 227 | > |
| 229 | border: "1px solid var(--border, #ddd)", | 228 | <ConnectionField label="Origin" value={connection.origin} /> |
| 230 | background: "var(--muted, #f5f5f5)", | 229 | <ConnectionField label="API" value={connection.apiBaseUrl} /> |
| 231 | }} | 230 | <ConnectionField label="Auth" value={authLabel} /> |
| 232 | > | 231 | <ConnectionField label="Kind" value={connection.kind} /> |
| 233 | <div style={{ fontSize: "0.8rem", fontWeight: 600 }}>CLI profile</div> | 232 | </section> |
| 234 | <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> | ||
| 235 | {cliProfileCommand} | ||
| 236 | </code> | ||
| 237 | <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> | ||
| 238 | {cliUseCommand} | ||
| 239 | </code> | ||
| 240 | </section> | ||
| 241 | 233 | ||
| 242 | <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}> | 234 | <section |
| 243 | <label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | 235 | style={{ |
| 244 | <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Port</span> | 236 | display: "flex", |
| 245 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | 237 | flexDirection: "column", |
| 246 | <input | 238 | gap: "0.5rem", |
| 247 | type="number" | 239 | marginBottom: "1.5rem", |
| 248 | min={1} | 240 | padding: "0.85rem", |
| 249 | max={65535} | 241 | borderRadius: 6, |
| 250 | value={draft.port} | 242 | border: "1px solid var(--border, #ddd)", |
| 251 | onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) })} | 243 | background: "var(--muted, #f5f5f5)", |
| 252 | style={{ | 244 | }} |
| 253 | padding: "0.5rem 0.7rem", | 245 | > |
| 254 | borderRadius: 6, | 246 | <div style={{ fontSize: "0.8rem", fontWeight: 600 }}>CLI profile</div> |
| 255 | border: "1px solid var(--border, #ddd)", | 247 | <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> |
| 256 | fontFamily: "inherit", | 248 | {cliProfileCommand} |
| 257 | fontSize: "0.95rem", | 249 | </code> |
| 258 | width: "8rem", | 250 | <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> |
| 259 | }} | 251 | {cliUseCommand} |
| 260 | /> | 252 | </code> |
| 261 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | 253 | </section> |
| 262 | Changes restart the connection at <code>http://127.0.0.1:{draft.port}</code>. | ||
| 263 | </span> | ||
| 264 | </label> | ||
| 265 | 254 | ||
| 266 | <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | 255 | <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}> |
| 267 | <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Bearer token</span> | 256 | <label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> |
| 268 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | 257 | <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Port</span> |
| 269 | <code | ||
| 270 | style={{ | ||
| 271 | flex: 1, | ||
| 272 | padding: "0.5rem 0.7rem", | ||
| 273 | borderRadius: 6, | ||
| 274 | border: "1px solid var(--border, #ddd)", | ||
| 275 | background: "var(--muted, #f5f5f5)", | ||
| 276 | fontSize: "0.85rem", | ||
| 277 | overflow: "auto", | ||
| 278 | whiteSpace: "nowrap", | ||
| 279 | }} | ||
| 280 | > | ||
| 281 | {authToken ?? "—"} | ||
| 282 | </code> | ||
| 283 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | 258 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} |
| 284 | <button | 259 | <input |
| 285 | type="button" | 260 | type="number" |
| 286 | onClick={() => void rotate()} | 261 | min={1} |
| 287 | disabled={status !== "idle"} | 262 | max={65535} |
| 263 | value={draft.port} | ||
| 264 | onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) })} | ||
| 288 | style={{ | 265 | style={{ |
| 289 | padding: "0.45rem 0.85rem", | 266 | padding: "0.5rem 0.7rem", |
| 290 | borderRadius: 6, | 267 | borderRadius: 6, |
| 291 | border: "1px solid var(--border, #ddd)", | 268 | border: "1px solid var(--border, #ddd)", |
| 292 | background: "var(--background, white)", | ||
| 293 | fontFamily: "inherit", | 269 | fontFamily: "inherit", |
| 294 | fontSize: "0.85rem", | 270 | fontSize: "0.95rem", |
| 295 | cursor: status === "idle" ? "pointer" : "default", | 271 | width: "8rem", |
| 296 | }} | 272 | }} |
| 297 | > | 273 | /> |
| 298 | Rotate | ||
| 299 | </button> | ||
| 300 | </div> | ||
| 301 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | ||
| 302 | The sidecar enforces this token on <code>/api</code> and <code>/mcp</code>. Rotating it | ||
| 303 | restarts the connection and invalidates existing MCP client configs — re-run your | ||
| 304 | connect command afterwards. | ||
| 305 | </span> | ||
| 306 | </div> | ||
| 307 | |||
| 308 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | ||
| 309 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | ||
| 310 | <button | ||
| 311 | type="button" | ||
| 312 | disabled={!dirty || status !== "idle"} | ||
| 313 | onClick={() => void apply({ port: draft.port })} | ||
| 314 | style={{ | ||
| 315 | padding: "0.55rem 1.1rem", | ||
| 316 | borderRadius: 6, | ||
| 317 | border: "1px solid transparent", | ||
| 318 | background: dirty ? "var(--primary, #0d0d10)" : "var(--muted, #eee)", | ||
| 319 | color: dirty ? "var(--primary-foreground, white)" : "var(--muted-foreground, #888)", | ||
| 320 | fontFamily: "inherit", | ||
| 321 | fontSize: "0.9rem", | ||
| 322 | cursor: dirty && status === "idle" ? "pointer" : "default", | ||
| 323 | }} | ||
| 324 | > | ||
| 325 | {status === "saving" | ||
| 326 | ? "Saving…" | ||
| 327 | : status === "restarting" | ||
| 328 | ? "Restarting server…" | ||
| 329 | : "Save"} | ||
| 330 | </button> | ||
| 331 | {error && ( | ||
| 332 | <span style={{ fontSize: "0.8rem", color: "var(--destructive, #c00)" }}>{error}</span> | ||
| 333 | )} | ||
| 334 | </div> | ||
| 335 | |||
| 336 | {bridge.exportDiagnostics && ( | ||
| 337 | <section | ||
| 338 | style={{ | ||
| 339 | display: "flex", | ||
| 340 | flexDirection: "column", | ||
| 341 | gap: "0.5rem", | ||
| 342 | padding: "0.85rem", | ||
| 343 | borderRadius: 6, | ||
| 344 | border: "1px solid var(--border, #ddd)", | ||
| 345 | }} | ||
| 346 | > | ||
| 347 | <div style={{ fontSize: "0.875rem", fontWeight: 600 }}>Diagnostics</div> | ||
| 348 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | 274 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> |
| 349 | Packs app and server logs, crash dumps, and version info into a zip in your Downloads | 275 | Changes restart the connection at <code>http://127.0.0.1:{draft.port}</code>. |
| 350 | folder — attach it when reporting a bug. Your sources, secrets, and bearer token are | ||
| 351 | not included. | ||
| 352 | </span> | 276 | </span> |
| 277 | </label> | ||
| 278 | |||
| 279 | <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | ||
| 280 | <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Bearer token</span> | ||
| 353 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | 281 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> |
| 282 | <code | ||
| 283 | style={{ | ||
| 284 | flex: 1, | ||
| 285 | padding: "0.5rem 0.7rem", | ||
| 286 | borderRadius: 6, | ||
| 287 | border: "1px solid var(--border, #ddd)", | ||
| 288 | background: "var(--muted, #f5f5f5)", | ||
| 289 | fontSize: "0.85rem", | ||
| 290 | overflow: "auto", | ||
| 291 | whiteSpace: "nowrap", | ||
| 292 | }} | ||
| 293 | > | ||
| 294 | {authToken ?? "—"} | ||
| 295 | </code> | ||
| 354 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | 296 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} |
| 355 | <button | 297 | <button |
| 356 | type="button" | 298 | type="button" |
| 357 | onClick={() => void exportDiagnostics()} | 299 | onClick={() => void rotate()} |
| 358 | disabled={diagnostics.state === "exporting"} | 300 | disabled={status !== "idle"} |
| 359 | style={{ | 301 | style={{ |
| 360 | padding: "0.45rem 0.85rem", | 302 | padding: "0.45rem 0.85rem", |
| 361 | borderRadius: 6, | 303 | borderRadius: 6, |
| @@ -363,20 +305,92 @@ function SettingsPage() { · 1 unmodified line | |||
| 363 | background: "var(--background, white)", | 305 | background: "var(--background, white)", |
| 364 | fontFamily: "inherit", | 306 | fontFamily: "inherit", |
| 365 | fontSize: "0.85rem", | 307 | fontSize: "0.85rem", |
| 366 | cursor: diagnostics.state === "exporting" ? "default" : "pointer", | 308 | cursor: status === "idle" ? "pointer" : "default", |
| 367 | width: "fit-content", | ||
| 368 | }} | 309 | }} |
| 369 | > | 310 | > |
| 370 | {diagnostics.state === "exporting" ? "Exporting…" : "Export diagnostics"} | 311 | Rotate |
| 371 | </button> | 312 | </button> |
| 372 | {diagnostics.state === "done" && ( | ||
| 373 | <code style={{ fontSize: "0.75rem", overflow: "auto", whiteSpace: "nowrap" }}> | ||
| 374 | {diagnostics.path} | ||
| 375 | </code> | ||
| 376 | )} | ||
| 377 | </div> | 313 | </div> |
| 378 | </section> | 314 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> |
| 379 | )} | 315 | The sidecar enforces this token on <code>/api</code> and <code>/mcp</code>. Rotating |
| 316 | it restarts the connection and invalidates existing MCP client configs — re-run your | ||
| 317 | connect command afterwards. | ||
| 318 | </span> | ||
| 319 | </div> | ||
| 320 | |||
| 321 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | ||
| 322 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | ||
| 323 | <button | ||
| 324 | type="button" | ||
| 325 | disabled={!dirty || status !== "idle"} | ||
| 326 | onClick={() => void apply({ port: draft.port })} | ||
| 327 | style={{ | ||
| 328 | padding: "0.55rem 1.1rem", | ||
| 329 | borderRadius: 6, | ||
| 330 | border: "1px solid transparent", | ||
| 331 | background: dirty ? "var(--primary, #0d0d10)" : "var(--muted, #eee)", | ||
| 332 | color: dirty ? "var(--primary-foreground, white)" : "var(--muted-foreground, #888)", | ||
| 333 | fontFamily: "inherit", | ||
| 334 | fontSize: "0.9rem", | ||
| 335 | cursor: dirty && status === "idle" ? "pointer" : "default", | ||
| 336 | }} | ||
| 337 | > | ||
| 338 | {status === "saving" | ||
| 339 | ? "Saving…" | ||
| 340 | : status === "restarting" | ||
| 341 | ? "Restarting server…" | ||
| 342 | : "Save"} | ||
| 343 | </button> | ||
| 344 | {error && ( | ||
| 345 | <span style={{ fontSize: "0.8rem", color: "var(--destructive, #c00)" }}>{error}</span> | ||
| 346 | )} | ||
| 347 | </div> | ||
| 348 | |||
| 349 | {bridge.exportDiagnostics && ( | ||
| 350 | <section | ||
| 351 | style={{ | ||
| 352 | display: "flex", | ||
| 353 | flexDirection: "column", | ||
| 354 | gap: "0.5rem", | ||
| 355 | padding: "0.85rem", | ||
| 356 | borderRadius: 6, | ||
| 357 | border: "1px solid var(--border, #ddd)", | ||
| 358 | }} | ||
| 359 | > | ||
| 360 | <div style={{ fontSize: "0.875rem", fontWeight: 600 }}>Diagnostics</div> | ||
| 361 | <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | ||
| 362 | Packs app and server logs, crash dumps, and version info into a zip in your | ||
| 363 | Downloads folder — attach it when reporting a bug. Your sources, secrets, and bearer | ||
| 364 | token are not included. | ||
| 365 | </span> | ||
| 366 | <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | ||
| 367 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | ||
| 368 | <button | ||
| 369 | type="button" | ||
| 370 | onClick={() => void exportDiagnostics()} | ||
| 371 | disabled={diagnostics.state === "exporting"} | ||
| 372 | style={{ | ||
| 373 | padding: "0.45rem 0.85rem", | ||
| 374 | borderRadius: 6, | ||
| 375 | border: "1px solid var(--border, #ddd)", | ||
| 376 | background: "var(--background, white)", | ||
| 377 | fontFamily: "inherit", | ||
| 378 | fontSize: "0.85rem", | ||
| 379 | cursor: diagnostics.state === "exporting" ? "default" : "pointer", | ||
| 380 | width: "fit-content", | ||
| 381 | }} | ||
| 382 | > | ||
| 383 | {diagnostics.state === "exporting" ? "Exporting…" : "Export diagnostics"} | ||
| 384 | </button> | ||
| 385 | {diagnostics.state === "done" && ( | ||
| 386 | <code style={{ fontSize: "0.75rem", overflow: "auto", whiteSpace: "nowrap" }}> | ||
| 387 | {diagnostics.path} | ||
| 388 | </code> | ||
| 389 | )} | ||
| 390 | </div> | ||
| 391 | </section> | ||
| 392 | )} | ||
| 393 | </div> | ||
| 380 | </div> | 394 | </div> |
| 381 | </div> | 395 | </div> |
| 382 | ); | 396 | ); |
| @@ -22,6 +22,7 @@ import * as React from "react"; · 21 unmodified lines | |||
| 22 | import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection"; | 22 | import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection"; |
| 23 | 23 | ||
| 24 | const STORAGE_KEY = "executor.authToken"; | 24 | const STORAGE_KEY = "executor.authToken"; |
| 25 | const DESKTOP_LAUNCH_CACHE_BUST_PARAM = "_executor_desktop_launch"; | ||
| 25 | 26 | ||
| 26 | const isDesktopBridge = (): boolean => | 27 | const isDesktopBridge = (): boolean => |
| 27 | typeof globalThis.window?.executor?.getServerConnection === "function"; | 28 | typeof globalThis.window?.executor?.getServerConnection === "function"; |
| @@ -57,10 +58,23 @@ const applyBearer = (token: string): void => { · 29 unmodified lines | |||
| 57 | * localStorage. Identical in dev and prod. | 58 | * localStorage. Identical in dev and prod. |
| 58 | */ | 59 | */ |
| 59 | export const bootstrapLocalAuthToken = (): void => { | 60 | export const bootstrapLocalAuthToken = (): void => { |
| 60 | if (isDesktopBridge()) return; | ||
| 61 | |||
| 62 | const url = globalThis.window ? new URL(window.location.href) : null; | 61 | const url = globalThis.window ? new URL(window.location.href) : null; |
| 63 | const fromUrl = url?.searchParams.get("_token") ?? null; | 62 | const fromUrl = url?.searchParams.get("_token") ?? null; |
| 63 | const stripCacheBust = url?.searchParams.has(DESKTOP_LAUNCH_CACHE_BUST_PARAM) ?? false; | ||
| 64 | if (stripCacheBust) { | ||
| 65 | url!.searchParams.delete(DESKTOP_LAUNCH_CACHE_BUST_PARAM); | ||
| 66 | } | ||
| 67 | |||
| 68 | if (isDesktopBridge()) { | ||
| 69 | if (fromUrl) { | ||
| 70 | url!.searchParams.delete("_token"); | ||
| 71 | } | ||
| 72 | if (stripCacheBust || fromUrl) { | ||
| 73 | globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); | ||
| 74 | } | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | |||
| 64 | if (fromUrl) { | 78 | if (fromUrl) { |
| 65 | persistToken(fromUrl); | 79 | persistToken(fromUrl); |
| 66 | url!.searchParams.delete("_token"); | 80 | url!.searchParams.delete("_token"); |
| @@ -69,6 +83,10 @@ export const bootstrapLocalAuthToken = (): void => { · 2 unmodified lines | |||
| 69 | return; | 83 | return; |
| 70 | } | 84 | } |
| 71 | 85 | ||
| 86 | if (stripCacheBust) { | ||
| 87 | globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); | ||
| 88 | } | ||
| 89 | |||
| 72 | const stored = readStoredToken(); | 90 | const stored = readStoredToken(); |
| 73 | if (stored) applyBearer(stored); | 91 | if (stored) applyBearer(stored); |
| 74 | }; | 92 | }; |
| @@ -127,6 +127,9 @@ interface ExecutorServerConnectionContextValue { · 126 unmodified lines | |||
| 127 | const ExecutorServerConnectionContext = | 127 | const ExecutorServerConnectionContext = |
| 128 | React.createContext<ExecutorServerConnectionContextValue | null>(null); | 128 | React.createContext<ExecutorServerConnectionContextValue | null>(null); |
| 129 | 129 | ||
| 130 | const hasDesktopServerConnectionBridge = (): boolean => | ||
| 131 | typeof globalThis.window?.executor?.getServerConnection === "function"; | ||
| 132 | |||
| 130 | export function ExecutorServerConnectionProvider( | 133 | export function ExecutorServerConnectionProvider( |
| 131 | props: React.PropsWithChildren<{ | 134 | props: React.PropsWithChildren<{ |
| 132 | readonly connection?: ExecutorServerConnectionInput; | 135 | readonly connection?: ExecutorServerConnectionInput; |
| @@ -142,6 +145,7 @@ export function ExecutorServerConnectionProvider( · 9 unmodified lines | |||
| 142 | const [connection, setConnection] = React.useState(initialConnection); | 145 | const [connection, setConnection] = React.useState(initialConnection); |
| 143 | const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => { | 146 | const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => { |
| 144 | const next = normalizeExecutorServerConnection(input); | 147 | const next = normalizeExecutorServerConnection(input); |
| 148 | if (hasDesktopServerConnectionBridge() && next.kind !== "desktop-sidecar") return; | ||
| 145 | activeConnection = next; | 149 | activeConnection = next; |
| 146 | setConnection(next); | 150 | setConnection(next); |
| 147 | }, []); | 151 | }, []); |
| @@ -160,13 +164,13 @@ export function ExecutorServerConnectionProvider( · 12 unmodified lines | |||
| 160 | if (typeof bridge?.getServerConnection !== "function") return; | 164 | if (typeof bridge?.getServerConnection !== "function") return; |
| 161 | 165 | ||
| 162 | let cancelled = false; | 166 | let cancelled = false; |
| 163 | const initialKey = activeConnection.key; | ||
| 164 | void bridge.getServerConnection().then( | 167 | void bridge.getServerConnection().then( |
| 165 | (input) => { | 168 | (input) => { |
| 166 | if (cancelled || !input) return; | 169 | if (cancelled || !input) return; |
| 167 | const next = normalizeExecutorServerConnection(input); | 170 | const next = normalizeExecutorServerConnection(input); |
| 168 | setConnection((current) => { | 171 | setConnection(() => { |
| 169 | if (current.key !== initialKey) return current; | 172 | // Electron loads the UI from a local URL before the async bridge |
| 173 | // answers. Once it does, the bridge is the authoritative app server. | ||
| 170 | activeConnection = next; | 174 | activeConnection = next; |
| 171 | return next; | 175 | return next; |
| 172 | }); | 176 | }); |