Unified · Split
| @@ -89,7 +89,7 @@ jobs: · 88 unmodified lines | ||
| 89 | 89 | - name: Build web app |
| 90 | 90 | run: bun run --filter @executor-js/local build |
| 91 | 91 | |
| 92 | − - name: Build sidecar + stage web UI | |
| 92 | + - name: Build bundled executor | |
| 93 | 93 | env: |
| 94 | 94 | BUN_TARGET: bun-linux-x64 |
| 95 | 95 | run: bun ./scripts/build-sidecar.ts |
| @@ -2,8 +2,8 @@ name: Publish Desktop App · 1 unmodified line | ||
| 2 | 2 | run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" |
| 3 | 3 | |
| 4 | 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 | |
| 6 | −# sidecar bundled in `resources/sidecar/`, then attaches them to the GitHub | |
| 5 | +# lands. Builds Electron distributables for mac/win/linux with the compiled | |
| 6 | +# executor CLI bundled in `resources/executor/`, then attaches them to the GitHub | |
| 7 | 7 | # release matching the tag so electron-updater can pick them up. |
| 8 | 8 | |
| 9 | 9 | on: |
| @@ -97,17 +97,17 @@ jobs: · 87 unmodified lines | ||
| 97 | 97 | - name: Build web app |
| 98 | 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 | 101 | env: |
| 102 | 102 | BUN_TARGET: ${{ matrix.bun-target }} |
| 103 | 103 | run: bun ./scripts/build-sidecar.ts |
| 104 | 104 | working-directory: apps/desktop |
| 105 | 105 | |
| 106 | 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) — | |
| 108 | − # a regression dev mode can't catch because `bun run` resolves | |
| 107 | + # shipped local-server binaries that died on launch (missing libsql native | |
| 108 | + # binding) — a regression dev mode can't catch because `bun run` resolves | |
| 109 | 109 | # node_modules that `bun build --compile` does not bundle. |
| 110 | − - name: Smoke test compiled sidecar | |
| 110 | + - name: Smoke test bundled executor | |
| 111 | 111 | if: matrix.smoke |
| 112 | 112 | run: bun run test:smoke |
| 113 | 113 | working-directory: apps/desktop |
| @@ -30,6 +30,7 @@ · 29 unmodified lines | ||
| 30 | 30 | "@executor-js/runtime-quickjs": "workspace:*", |
| 31 | 31 | "@executor-js/sdk": "workspace:*", |
| 32 | 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 33 | + "@sentry/bun": "^10.57.0", | |
| 33 | 34 | "effect": "catalog:", |
| 34 | 35 | "quickjs-emscripten": "catalog:" |
| 35 | 36 | }, |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | canAutoStartLocalDaemonForHost, |
| 8 | 8 | isDevCliEntrypoint, |
| 9 | 9 | isExecutorServerReachable, |
| 10 | + planServiceInstall, | |
| 10 | 11 | } from "./daemon"; |
| 11 | 12 | |
| 12 | 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 | 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 | 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 | 58 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 40 | 59 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 41 | 60 | import { HttpApiClient } from "effect/unstable/httpapi"; |
| @@ -71,6 +90,7 @@ import { · 29 unmodified lines | ||
| 71 | 90 | isExecutorServerReachable, |
| 72 | 91 | isDevCliEntrypoint, |
| 73 | 92 | parseDaemonBaseUrl, |
| 93 | + planServiceInstall, | |
| 74 | 94 | spawnDetached, |
| 75 | 95 | waitForReachable, |
| 76 | 96 | waitForUnreachable, |
| @@ -100,7 +120,6 @@ import { · 23 unmodified lines | ||
| 100 | 120 | acquireLocalServerStartLock, |
| 101 | 121 | readLocalServerManifest, |
| 102 | 122 | releaseLocalServerStartLock, |
| 103 | − removeLocalServerManifest, | |
| 104 | 123 | removeLocalServerManifestIfOwnedBy, |
| 105 | 124 | resolveExecutorDataDir, |
| 106 | 125 | writeLocalServerManifest, |
| @@ -260,7 +279,7 @@ const makeLocalServerManifest = (input: { · 153 unmodified lines | ||
| 260 | 279 | scopeDir: currentScopeDirForManifest(), |
| 261 | 280 | connection: input.connection, |
| 262 | 281 | owner: { |
| 263 | − client: "cli", | |
| 282 | + client: process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli", | |
| 264 | 283 | version: CLI_VERSION, |
| 265 | 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 | 343 | const publishLocalServerManifest = (input: { |
| 290 | 344 | readonly kind: ExecutorLocalServerKind; |
| 291 | 345 | readonly connection: ExecutorServerConnection; |
| @@ -897,7 +951,7 @@ const runDaemonSession = (input: { · 605 unmodified lines | ||
| 897 | 951 | // and crash-loop under KeepAlive. (Found by a real reboot test with |
| 898 | 952 | // integration data in the DB.) |
| 899 | 953 | if (process.env.EXECUTOR_SUPERVISED) { |
| 900 | − yield* removeLocalServerManifest().pipe(Effect.ignore); | |
| 954 | + yield* takeOverActiveLocalServer().pipe(Effect.ignore); | |
| 901 | 955 | } else { |
| 902 | 956 | yield* assertNoOtherActiveLocalServer(); |
| 903 | 957 | } |
| @@ -907,15 +961,34 @@ const runDaemonSession = (input: { · 3 unmodified lines | ||
| 907 | 961 | if (existing) { |
| 908 | 962 | const existingUrl = daemonBaseUrl(existing.hostname, existing.port); |
| 909 | 963 | if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { |
| 910 | − return yield* Effect.fail( | |
| 911 | − new Error( | |
| 912 | − [ | |
| 913 | − `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, | |
| 914 | − `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, | |
| 915 | − `Stop it first: ${cliPrefix} daemon stop`, | |
| 916 | − ].join("\n"), | |
| 917 | − ), | |
| 918 | − ); | |
| 964 | + if (process.env.EXECUTOR_SUPERVISED) { | |
| 965 | + yield* terminatePid(existing.pid).pipe(Effect.ignore); | |
| 966 | + const stopped = yield* waitForUnreachable({ | |
| 967 | + check: isServerReachable(existingUrl), | |
| 968 | + timeoutMs: DAEMON_STOP_TIMEOUT_MS, | |
| 969 | + intervalMs: DAEMON_BOOT_POLL_MS, | |
| 970 | + }); | |
| 971 | + if (!stopped) { | |
| 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 | 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 | 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 | 2212 | const servicePortOption = () => |
| 2129 | 2213 | Options.integer("port") |
| 2130 | 2214 | .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) |
| @@ -2164,26 +2248,36 @@ const installService = (port: number, commandName: string) => · 33 unmodified lines | ||
| 2164 | 2248 | return; |
| 2165 | 2249 | } |
| 2166 | 2250 | |
| 2167 | − // Don't fight an already-running local server against the same data dir | |
| 2168 | − // (a desktop sidecar, a foreground `executor web`, or an existing daemon). | |
| 2169 | − const active = yield* readActiveLocalServerManifest(); | |
| 2170 | − if (active) { | |
| 2171 | − const status = yield* backend.status(); | |
| 2172 | − if (status.registered && status.running && active.kind === "cli-daemon") { | |
| 2251 | + const status = yield* backend.status(); | |
| 2252 | + const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); | |
| 2253 | + const plan = planServiceInstall({ | |
| 2254 | + registered: status.registered, | |
| 2255 | + running: status.running, | |
| 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 | 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 | 2283 | const path = yield* PlatformPath.Path; |
| @@ -13,12 +13,14 @@ describe("service unit generation", () => { · 12 unmodified lines | ||
| 13 | 13 | const launchdInput = { |
| 14 | 14 | label: "sh.executor.daemon", |
| 15 | 15 | programArguments: [ |
| 16 | − "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", | |
| 16 | + "/Applications/Executor.app/Contents/Resources/executor/executor", | |
| 17 | 17 | "daemon", |
| 18 | 18 | "run", |
| 19 | 19 | "--foreground", |
| 20 | 20 | "--port", |
| 21 | 21 | "4789", |
| 22 | + "--hostname", | |
| 23 | + "127.0.0.1", | |
| 22 | 24 | ], |
| 23 | 25 | environment: { |
| 24 | 26 | EXECUTOR_SUPERVISED: "1", |
| @@ -65,13 +67,24 @@ describe("service unit generation", () => { · 40 unmodified lines | ||
| 65 | 67 | |
| 66 | 68 | it("renders a systemd --user unit with crash-only restart", () => { |
| 67 | 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 | 80 | environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, |
| 70 | 81 | workingDirectory: "/home/x/.executor", |
| 71 | 82 | stdoutPath: "/home/x/.executor/logs/daemon.log", |
| 72 | 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 | 88 | expect(unit).toContain("Restart=on-failure"); |
| 76 | 89 | expect(unit).toContain("WantedBy=default.target"); |
| 77 | 90 | expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); |
| @@ -92,7 +105,7 @@ describe("service unit generation", () => { · 14 unmodified lines | ||
| 92 | 105 | expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); |
| 93 | 106 | expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); |
| 94 | 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 | 110 | expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); |
| 98 | 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 | 138 | "--foreground", |
| 139 | 139 | "--port", |
| 140 | 140 | String(descriptor.port), |
| 141 | + "--hostname", | |
| 142 | + "127.0.0.1", | |
| 141 | 143 | ]; |
| 142 | 144 | |
| 143 | 145 | const serviceEnvironment = ( |
| 144 | 146 | descriptor: ServiceDescriptor, |
| 145 | 147 | dataDir: string, |
| 146 | −): Record<string, string> => ({ | |
| 147 | − // Marks the process as OS-supervised so the daemon resolves its bearer token | |
| 148 | − // from the durable 0600 auth.json (the secret is never in the unit itself). | |
| 149 | − EXECUTOR_SUPERVISED: "1", | |
| 150 | − // Pin the data dir explicitly: launchd/systemd give a minimal environment and | |
| 151 | − // we never want the daemon to fall back to a different home than the user's. | |
| 152 | − EXECUTOR_DATA_DIR: dataDir, | |
| 153 | − // Stamp the installing version so `service status` can flag drift after an | |
| 154 | − // upgrade where the unit still points at an older binary path. | |
| 155 | − EXECUTOR_SERVICE_VERSION: descriptor.version, | |
| 156 | − // A launchd/systemd unit starts with a bare PATH — without the user's PATH | |
| 157 | − // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may | |
| 158 | − // shell out to. `service install` runs from the user's shell, so its own | |
| 159 | − // PATH is the right one to bake in. (Reference: opencode shell-env capture.) | |
| 160 | − ...(process.env.PATH ? { PATH: process.env.PATH } : {}), | |
| 161 | −}); | |
| 148 | +): Record<string, string> => { | |
| 149 | + const passThroughKeys = [ | |
| 150 | + "EXECUTOR_CLIENT", | |
| 151 | + "EXECUTOR_SENTRY_DSN", | |
| 152 | + "EXECUTOR_SENTRY_RELEASE", | |
| 153 | + "EXECUTOR_SENTRY_ENVIRONMENT", | |
| 154 | + "EXECUTOR_RUN_ID", | |
| 155 | + ] as const; | |
| 156 | + const passThrough = Object.fromEntries( | |
| 157 | + passThroughKeys.flatMap((key) => { | |
| 158 | + const value = process.env[key]; | |
| 159 | + return value ? [[key, value] as const] : []; | |
| 160 | + }), | |
| 161 | + ); | |
| 162 | + | |
| 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 | 185 | // macOS — launchd LaunchAgent (fully built) |
| @@ -7,12 +7,12 @@ · 6 unmodified lines | ||
| 7 | 7 | <true/> |
| 8 | 8 | <key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
| 9 | 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 | 11 | reads EXECUTOR_* env vars. Hardened runtime would strip them |
| 12 | 12 | without this entitlement. --> |
| 13 | 13 | <key>com.apple.security.cs.allow-dyld-environment-variables</key> |
| 14 | 14 | <true/> |
| 15 | − <!-- The Bun-compiled executor-sidecar binary inside Resources/sidecar/ | |
| 15 | + <!-- The Bun-compiled executor binary inside Resources/executor/ | |
| 16 | 16 | loads dylibs Apple's library validator doesn't recognize as part |
| 17 | 17 | of the Electron bundle's signing chain. --> |
| 18 | 18 | <key>com.apple.security.cs.disable-library-validation</key> |
| @@ -7,20 +7,15 @@ const config: Configuration = { · 6 unmodified lines | ||
| 7 | 7 | directories: { |
| 8 | 8 | output: "dist", |
| 9 | 9 | // Static build inputs live in build/ (icon.png, entitlements.mac.plist). |
| 10 | − // Runtime resources staged at build time (sidecar binary, web-ui) live | |
| 11 | − // in resources/ and are wired in via `extraResources` below. | |
| 10 | + // Runtime resources staged at build time (the bundled executor CLI binary) | |
| 11 | + // live in resources/ and are wired in via `extraResources` below. | |
| 12 | 12 | buildResources: "build", |
| 13 | 13 | }, |
| 14 | 14 | files: ["out/**/*", "package.json"], |
| 15 | 15 | extraResources: [ |
| 16 | 16 | { |
| 17 | − from: "resources/sidecar/", | |
| 18 | − to: "sidecar/", | |
| 19 | − filter: ["**/*"], | |
| 20 | − }, | |
| 21 | − { | |
| 22 | − from: "resources/web-ui/", | |
| 23 | − to: "web-ui/", | |
| 17 | + from: "resources/executor/", | |
| 18 | + to: "executor/", | |
| 24 | 19 | filter: ["**/*"], |
| 25 | 20 | }, |
| 26 | 21 | ], |
| @@ -29,7 +24,7 @@ const config: Configuration = { · 2 unmodified lines | ||
| 29 | 24 | // Do NOT pin `arch:` inside the target objects. The publish workflow's |
| 30 | 25 | // matrix passes `--arm64` / `--x64` per leg; a config-level arch list |
| 31 | 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 | 28 | // -86 / EBADARCH on Apple Silicon). The CLI flag is the source of truth. |
| 34 | 29 | target: ["dmg", "zip"], |
| 35 | 30 | hardenedRuntime: true, |
| @@ -45,10 +40,10 @@ const config: Configuration = { · 9 unmodified lines | ||
| 45 | 40 | }, |
| 46 | 41 | // Same arch rule as mac (see comment above): never pin `arch:` in the |
| 47 | 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 | 44 | // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each |
| 50 | 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 | 47 | win: { |
| 53 | 48 | target: ["nsis"], |
| 54 | 49 | }, |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 3 | // preload `out/`), but skips Apple signing/notarization so it builds with no |
| 4 | 4 | // CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through |
| 5 | 5 | // Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | "scripts": { |
| 10 | 10 | "predev": "bun run --filter @executor-js/local build", |
| 11 | 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 | 13 | "build": "electron-vite build", |
| 14 | 14 | "preview": "electron-vite preview", |
| 15 | 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`. | |
| 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. | |
| 2 | + * Stage the production local-server binary for the packaged desktop app. | |
| 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"; | |
| 14 | −import { existsSync } from "node:fs"; | |
| 15 | −import { createRequire } from "node:module"; | |
| 16 | −import { dirname, join, resolve } from "node:path"; | |
| 17 | −import { $ } from "bun"; | |
| 8 | +import { chmod, cp, mkdir, rm } from "node:fs/promises"; | |
| 9 | +import { resolve, join } from "node:path"; | |
| 18 | 10 | |
| 19 | 11 | const ROOT = resolve(import.meta.dir, ".."); |
| 20 | 12 | const REPO_ROOT = resolve(ROOT, "../.."); |
| 21 | −const APPS_LOCAL = resolve(REPO_ROOT, "apps/local"); | |
| 22 | −const SIDECAR_ENTRY = resolve(ROOT, "src/sidecar/server.ts"); | |
| 23 | −const SIDECAR_OUT_DIR = resolve(ROOT, "resources/sidecar"); | |
| 24 | −const WEB_UI_OUT_DIR = resolve(ROOT, "resources/web-ui"); | |
| 25 | −const APPS_LOCAL_DIST = resolve(APPS_LOCAL, "dist"); | |
| 26 | −const EMBEDDED_MIGRATIONS_PATH = resolve(APPS_LOCAL, "src/db/embedded-migrations.gen.ts"); | |
| 27 | −const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record<string, string> | null = null;\n\nexport default migrations;\n`; | |
| 28 | − | |
| 29 | −/** | |
| 30 | − * Cross-compile target for `bun build --compile`. When unset we use Bun's | |
| 31 | − * default `bun` target (the runner's own platform). CI passes a specific | |
| 32 | − * value like `bun-darwin-x64` to produce binaries for other platforms from | |
| 33 | − * a single matrix entry. | |
| 34 | − */ | |
| 35 | −const BUN_TARGET = process.env.BUN_TARGET ?? "bun"; | |
| 36 | −const targetIsWindows = BUN_TARGET.includes("windows") || process.platform === "win32"; | |
| 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; | |
| 13 | +const CLI_ROOT = resolve(REPO_ROOT, "apps/cli"); | |
| 14 | +const EXECUTOR_OUT_DIR = resolve(ROOT, "resources/executor"); | |
| 15 | + | |
| 16 | +const platformName = (platform: NodeJS.Platform): string => | |
| 17 | + platform === "win32" ? "windows" : platform; | |
| 18 | + | |
| 19 | +const currentTargetPackage = (): string => | |
| 20 | + `executor-${platformName(process.platform)}-${process.arch}`; | |
| 21 | + | |
| 22 | +const targetPackageFromBunTarget = (target: string | undefined): string | null => { | |
| 23 | + if (!target || target === "bun") return null; | |
| 24 | + const normalized = target | |
| 25 | + .replace(/^bun-/, "") | |
| 26 | + .replace(/^windows-/, "windows-") | |
| 27 | + .replace(/^win32-/, "windows-"); | |
| 28 | + return `executor-${normalized}`; | |
| 161 | 29 | }; |
| 162 | 30 | |
| 163 | −// The v1→v2 data migration replays the legacy v1 drizzle chain | |
| 164 | −// (apps/local/drizzle-legacy-v1) before reading a legacy database. The | |
| 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 | −} | |
| 31 | +const targetPackage = targetPackageFromBunTarget(process.env.BUN_TARGET) ?? currentTargetPackage(); | |
| 32 | +const targetArgs = process.env.BUN_TARGET ? ["--target", targetPackage] : ["--single"]; | |
| 198 | 33 | |
| 199 | −// Cross-target builds (e.g. the mac x64 leg on an arm64 runner) need the other | |
| 200 | −// platform's optional native packages on disk before we can stage them. | |
| 201 | −// `--cpu=* --os=*` extracts them all without modifying the lockfile. Mirrors | |
| 202 | −// apps/cli/src/build.ts — Bun.spawn, not Bun.$, because the shell | |
| 203 | −// glob-expands the bare `*` in `--cpu=*` and fails with "no matches found". | |
| 204 | −if (!targetIsCurrentPlatform) { | |
| 205 | − console.log("[build-sidecar] installing optional native deps for all platforms..."); | |
| 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 | − } | |
| 34 | +console.log(`[build-sidecar] building CLI binary target ${targetPackage}`); | |
| 35 | +const build = Bun.spawn(["bun", "run", "src/build.ts", "binary", ...targetArgs], { | |
| 36 | + cwd: CLI_ROOT, | |
| 37 | + stdio: ["ignore", "inherit", "inherit"], | |
| 38 | +}); | |
| 39 | +if ((await build.exited) !== 0) { | |
| 40 | + throw new Error(`CLI binary build failed for ${targetPackage}`); | |
| 219 | 41 | } |
| 220 | 42 | |
| 221 | −// Resolve the native bindings up front so a missing platform package fails the | |
| 222 | −// build before the (slow) compile, and cross-target builds get a clear message. | |
| 223 | −const libsqlNativePath = resolveLibsqlNative(); | |
| 224 | −const keyringNativePath = resolveKeyringNative(); | |
| 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")); | |
| 43 | +const sourceBinDir = join(CLI_ROOT, "dist", targetPackage, "bin"); | |
| 44 | +await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true }); | |
| 45 | +await mkdir(EXECUTOR_OUT_DIR, { recursive: true }); | |
| 46 | +await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true }); | |
| 251 | 47 | |
| 252 | − console.log(`[build-sidecar] staging web UI → ${WEB_UI_OUT_DIR}`); | |
| 253 | − await cp(APPS_LOCAL_DIST, WEB_UI_OUT_DIR, { recursive: true }); | |
| 254 | −} finally { | |
| 255 | − await writeFile(EMBEDDED_MIGRATIONS_PATH, EMBEDDED_MIGRATIONS_STUB); | |
| 48 | +if (process.platform !== "win32") { | |
| 49 | + await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755); | |
| 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 | 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 | 6 | * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. |
| 7 | 7 | * |
| 8 | 8 | * Flow: |
| 9 | 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 | |
| 11 | − * and parse the `EXECUTOR_READY:<port>` sentinel. | |
| 10 | + * 2. Spawn the compiled `executor daemon run --foreground --port 0` | |
| 11 | + * and parse the ready URL. | |
| 12 | 12 | * 3. Connect via MCP streamable HTTP, call the `execute` tool with code |
| 13 | 13 | * that registers and invokes the OpenAPI tool, assert the answer |
| 14 | 14 | * round-trips as 42. |
| @@ -29,8 +29,8 @@ const ROOT = resolve(import.meta.dir, ".."); · 14 unmodified lines | ||
| 29 | 29 | const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); |
| 30 | 30 | const BINARY = resolve( |
| 31 | 31 | ROOT, |
| 32 | − "resources/sidecar", | |
| 33 | − process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar", | |
| 32 | + "resources/executor", | |
| 33 | + process.platform === "win32" ? "executor.exe" : "executor", | |
| 34 | 34 | ); |
| 35 | 35 | |
| 36 | 36 | const AUTH_TOKEN = "smoke-test-token"; |
| @@ -38,7 +38,7 @@ const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`; · 1 unmodified line | ||
| 38 | 38 | const READY_TIMEOUT_MS = 30_000; |
| 39 | 39 | |
| 40 | 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 | 42 | const fail = (msg: string): never => { |
| 43 | 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 | 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 | 246 | new Promise((resolveReady, rejectReady) => { |
| 247 | 247 | const deadline = setTimeout(() => { |
| 248 | 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 | 250 | }, READY_TIMEOUT_MS); |
| 251 | 251 | |
| 252 | 252 | let stdoutBuf = ""; |
| @@ -258,7 +258,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 5 unmodified lines | ||
| 258 | 258 | while (true) { |
| 259 | 259 | const { value, done } = await stderrReader.read(); |
| 260 | 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 | 268 | if (done) { |
| 269 | 269 | clearTimeout(deadline); |
| 270 | 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 | 272 | return; |
| 273 | 273 | } |
| 274 | 274 | const chunk = decoder.decode(value); |
| 275 | − process.stdout.write(`[sidecar-stdout] ${chunk}`); | |
| 275 | + process.stdout.write(`[executor-stdout] ${chunk}`); | |
| 276 | 276 | stdoutBuf += chunk; |
| 277 | − const match = /EXECUTOR_READY:(\d+)/.exec(stdoutBuf); | |
| 277 | + const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuf); | |
| 278 | 278 | if (match) { |
| 279 | 279 | clearTimeout(deadline); |
| 280 | 280 | resolveReady(parseInt(match[1]!, 10)); |
| @@ -344,14 +344,23 @@ const main = async () => { · 63 unmodified lines | ||
| 344 | 344 | console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); |
| 345 | 345 | |
| 346 | 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 | 359 | env: { |
| 349 | 360 | ...process.env, |
| 350 | − EXECUTOR_PORT: "0", | |
| 351 | − EXECUTOR_HOST: "127.0.0.1", | |
| 352 | − EXECUTOR_AUTH_TOKEN: AUTH_TOKEN, | |
| 353 | 361 | EXECUTOR_SCOPE_DIR: scopeDir, |
| 354 | 362 | EXECUTOR_DATA_DIR: dataDir, |
| 363 | + EXECUTOR_CLIENT: "desktop", | |
| 355 | 364 | XDG_DATA_HOME: xdgDir, |
| 356 | 365 | }, |
| 357 | 366 | stdin: "ignore", |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | } from "./diagnostics"; |
| 37 | 37 | import { sidecarCrashHtml } from "./crash-screen"; |
| 38 | 38 | import { |
| 39 | + bundledExecutorPath, | |
| 39 | 40 | installSupervisedService, |
| 40 | 41 | restartSupervisedService, |
| 41 | 42 | supervisedServiceStatus, |
| @@ -117,18 +118,66 @@ const stopConnection = async (conn: SidecarConnection): Promise<void> => { · 75 unmodified lines | ||
| 117 | 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 | 128 | // The supervised daemon (and the desktop sidecar) own this data dir — the same |
| 121 | 129 | // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. |
| 122 | 130 | const DESKTOP_DATA_DIR = join(homedir(), ".executor"); |
| 123 | 131 | |
| 124 | 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 | 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 | 177 | const deadline = Date.now() + timeoutMs; |
| 129 | 178 | for (;;) { |
| 130 | 179 | const attached = await attachToSupervisedDaemon(); |
| 131 | − if (attached) return attached; | |
| 180 | + if (attached && (options.port === undefined || attached.port === options.port)) return attached; | |
| 132 | 181 | if (Date.now() >= deadline) return null; |
| 133 | 182 | await delay(300); |
| 134 | 183 | } |
| @@ -140,7 +189,7 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 5 unmodified lines | ||
| 140 | 189 | title: "Keep Executor running in the background?", |
| 141 | 190 | message: "Keep your connections available after you quit Executor?", |
| 142 | 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 | 193 | buttons: ["Keep running in the background", "Not now"], |
| 145 | 194 | defaultId: 0, |
| 146 | 195 | cancelId: 1, |
| @@ -156,7 +205,21 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 9 unmodified lines | ||
| 156 | 205 | const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> => { |
| 157 | 206 | // 1. Already running → attach. |
| 158 | 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 | 224 | const status = await supervisedServiceStatus(); |
| 162 | 225 | if (!status.supported) return null; |
| @@ -189,9 +252,9 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 26 unmodified lines | ||
| 189 | 252 | return waitForSupervisedAttach(15_000); |
| 190 | 253 | }; |
| 191 | 254 | |
| 192 | −// Crash monitor for the supervised daemon: launchd restarts it on crash, but | |
| 193 | −// during that window the window's requests fail. Poll, show a reconnecting | |
| 194 | −// overlay while it's down, and reload once it's back. | |
| 255 | +// Crash monitor for the supervised daemon: the OS service manager restarts it | |
| 256 | +// on crash, but during that window the window's requests fail. Poll, show a | |
| 257 | +// reconnecting overlay while it's down, and reload once it's back. | |
| 195 | 258 | let supervisedMonitorTimer: ReturnType<typeof setInterval> | null = null; |
| 196 | 259 | let supervisedDaemonDown = false; |
| 197 | 260 | |
| @@ -219,7 +282,7 @@ const armSupervisedMonitor = () => { · 21 unmodified lines | ||
| 219 | 282 | supervisedDaemonDown = false; |
| 220 | 283 | connection = live; |
| 221 | 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 | 288 | }, 10_000); |
| @@ -380,7 +443,7 @@ const createWindow = async (conn: SidecarConnection) => { · 154 unmodified lines | ||
| 380 | 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 | 449 | const showPortInUseDialog = async (port: number) => { |
| @@ -418,11 +481,12 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 31 unmodified lines | ||
| 418 | 481 | // A supervised daemon owns its own process lifetime. Re-installing the unit |
| 419 | 482 | // rewrites settings such as the configured port, then launchd restarts it. |
| 420 | 483 | if (connection?.supervisedDaemon) { |
| 484 | + const port = getServerSettings().port; | |
| 421 | 485 | await installSupervisedService({ |
| 422 | − port: getServerSettings().port, | |
| 486 | + port, | |
| 423 | 487 | dataDir: DESKTOP_DATA_DIR, |
| 424 | 488 | }); |
| 425 | − const next = await waitForSupervisedAttach(30_000); | |
| 489 | + const next = await waitForSupervisedAttach(30_000, { port }); | |
| 426 | 490 | if (!next) { |
| 427 | 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 | 492 | throw new Error("Supervised daemon failed to restart — see Settings"); |
| @@ -430,7 +494,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 1 unmodified line | ||
| 430 | 494 | connection = next; |
| 431 | 495 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 432 | 496 | const window = liveMainWindow(); |
| 433 | − if (window) await window.loadURL(next.baseUrl); | |
| 497 | + if (window) await window.loadURL(webUrlForConnection(next)); | |
| 434 | 498 | return toDesktopServerConnection(next); |
| 435 | 499 | } |
| 436 | 500 | if (connection) { |
| @@ -445,7 +509,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 8 unmodified lines | ||
| 445 | 509 | connection = next; |
| 446 | 510 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 447 | 511 | const window = liveMainWindow(); |
| 448 | − if (window) await window.loadURL(next.baseUrl); | |
| 512 | + if (window) await window.loadURL(webUrlForConnection(next)); | |
| 449 | 513 | return toDesktopServerConnection(next); |
| 450 | 514 | }; |
| 451 | 515 | |
| @@ -457,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec · 5 unmodified lines | ||
| 457 | 521 | key: "desktop-sidecar", |
| 458 | 522 | origin: conn.baseUrl, |
| 459 | 523 | apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, |
| 460 | − displayName: "Desktop sidecar", | |
| 524 | + displayName: "Local Executor", | |
| 461 | 525 | }); |
| 462 | 526 | |
| 463 | 527 | const registerIpcHandlers = () => { |
| @@ -486,14 +550,14 @@ const registerIpcHandlers = () => { · 22 unmodified lines | ||
| 486 | 550 | connection = active; |
| 487 | 551 | installBearerAuthHeader(active.baseUrl, active.authToken); |
| 488 | 552 | const window = liveMainWindow(); |
| 489 | − if (window) await window.loadURL(active.baseUrl); | |
| 553 | + if (window) await window.loadURL(webUrlForConnection(active)); | |
| 490 | 554 | return toDesktopServerConnection(active); |
| 491 | 555 | } |
| 492 | 556 | return restartSidecarAndReload(); |
| 493 | 557 | }); |
| 494 | − // Background-service control surface (macOS) — lets a Settings toggle enable | |
| 495 | − // or disable the supervised daemon. Disabling tears down the service and | |
| 496 | − // falls back to a managed sidecar on next launch. | |
| 558 | + // Background-service control surface — lets a Settings toggle enable or | |
| 559 | + // disable the supervised daemon. Disabling tears down the service and falls | |
| 560 | + // back to a managed sidecar on next launch. | |
| 497 | 561 | ipcMain.handle("executor:service:status", () => supervisedServiceStatus()); |
| 498 | 562 | ipcMain.handle( |
| 499 | 563 | "executor:service:set-enabled", |
| @@ -512,7 +576,7 @@ const registerIpcHandlers = () => { · 12 unmodified lines | ||
| 512 | 576 | armSupervisedMonitor(); |
| 513 | 577 | installBearerAuthHeader(next.baseUrl, next.authToken); |
| 514 | 578 | const window = liveMainWindow(); |
| 515 | − if (window) await window.loadURL(next.baseUrl); | |
| 579 | + if (window) await window.loadURL(webUrlForConnection(next)); | |
| 516 | 580 | } |
| 517 | 581 | return true; |
| 518 | 582 | } |
| @@ -773,7 +837,7 @@ const boot = async () => { · 254 unmodified lines | ||
| 773 | 837 | // Prefer an OS-supervised daemon: attach to one that's running, kick one |
| 774 | 838 | // that's installed, or offer to install on first run. Quitting the app then |
| 775 | 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 | 841 | // dev and unsupported platforms keep managed-spawn. |
| 778 | 842 | if (app.isPackaged) { |
| 779 | 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 | −/** | |
| 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 | − | |
| 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 */ | |
| 16 | 2 | import { execFile } from "node:child_process"; |
| 17 | −import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 18 | −import { homedir, userInfo } from "node:os"; | |
| 3 | +import { existsSync } from "node:fs"; | |
| 19 | 4 | import { join } from "node:path"; |
| 20 | 5 | import { promisify } from "node:util"; |
| 21 | 6 | import { app } from "electron"; |
| 22 | 7 | import log from "electron-log/main.js"; |
| 8 | +import { sidecarCrashReportingEnv } from "./diagnostics"; | |
| 23 | 9 | |
| 24 | 10 | const serviceLog = log.scope("service"); |
| 25 | 11 | const execFileAsync = promisify(execFile); |
| @@ -32,112 +18,29 @@ interface CommandResult { · 6 unmodified lines | ||
| 32 | 18 | readonly stderr: string; |
| 33 | 19 | } |
| 34 | 20 | |
| 35 | −const runCommand = async (cmd: string, args: string[]): Promise<CommandResult> => { | |
| 36 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capture exit code rather than throw on non-zero | |
| 37 | − try { | |
| 38 | − const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "utf8" }); | |
| 39 | − return { code: 0, stdout, stderr }; | |
| 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"); | |
| 21 | +export interface SupervisedServiceStatus { | |
| 22 | + readonly supported: boolean; | |
| 23 | + readonly registered: boolean; | |
| 24 | + readonly running: boolean; | |
| 25 | +} | |
| 77 | 26 | |
| 78 | −interface PlistOptions { | |
| 79 | − readonly label: string; | |
| 80 | − readonly programArguments: ReadonlyArray<string>; | |
| 81 | − readonly environment: Record<string, string>; | |
| 82 | − readonly stdoutPath: string; | |
| 83 | − readonly stderrPath: string; | |
| 84 | − readonly workingDirectory: string; | |
| 27 | +export interface InstallOptions { | |
| 28 | + readonly port: number; | |
| 29 | + readonly dataDir: string; | |
| 85 | 30 | } |
| 86 | 31 | |
| 87 | −const generateLaunchdPlist = (options: PlistOptions): string => { | |
| 88 | − const programArgs = options.programArguments | |
| 89 | − .map((arg) => ` <string>${xmlEscape(arg)}</string>`) | |
| 90 | − .join("\n"); | |
| 91 | − const envEntries = Object.entries(options.environment) | |
| 92 | − .map( | |
| 93 | − ([key, value]) => | |
| 94 | − ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`, | |
| 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 | −}; | |
| 32 | +export const bundledExecutorPath = (): string => | |
| 33 | + join( | |
| 34 | + process.resourcesPath, | |
| 35 | + "executor", | |
| 36 | + process.platform === "win32" ? "executor.exe" : "executor", | |
| 37 | + ); | |
| 38 | + | |
| 39 | +const executorAvailable = (): boolean => app.isPackaged && existsSync(bundledExecutorPath()); | |
| 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 | 41 | const captureUserPath = async (): Promise<string | undefined> => { |
| 138 | 42 | const shell = process.env.SHELL; |
| 139 | 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 | 44 | try { |
| 142 | 45 | const { stdout } = await execFileAsync(shell, ["-ilc", 'printf "%s" "$PATH"'], { |
| 143 | 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 { | |
| 154 | − readonly supported: boolean; | |
| 155 | − readonly registered: boolean; | |
| 156 | − readonly running: boolean; | |
| 157 | −} | |
| 56 | +const serviceEnv = async (dataDir: string): Promise<NodeJS.ProcessEnv> => ({ | |
| 57 | + ...process.env, | |
| 58 | + ...(await captureUserPath().then((path) => (path ? { PATH: path } : {}))), | |
| 59 | + EXECUTOR_DATA_DIR: dataDir, | |
| 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 | 92 | export const supervisedServiceStatus = async (): Promise<SupervisedServiceStatus> => { |
| 162 | − if (!isSupported()) return { supported: false, registered: false, running: false }; | |
| 163 | − const registered = existsSync(plistPath()); | |
| 164 | − const print = await runCommand("launchctl", ["print", serviceTarget(currentUid())]); | |
| 165 | − return { supported: true, registered, running: print.code === 0 }; | |
| 93 | + if (!executorAvailable()) return { supported: false, registered: false, running: false }; | |
| 94 | + const dataDir = join(app.getPath("home"), ".executor"); | |
| 95 | + const result = await runExecutor(["service", "status"], { dataDir }); | |
| 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 | 108 | export const installSupervisedService = async (opts: InstallOptions): Promise<void> => { |
| 179 | − const uid = currentUid(); | |
| 180 | − const logs = join(opts.dataDir, "logs"); | |
| 181 | − mkdirSync(launchAgentsDir(), { recursive: true }); | |
| 182 | − mkdirSync(logs, { recursive: true }); | |
| 183 | − | |
| 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, | |
| 109 | + if (!executorAvailable()) { | |
| 110 | + throw new Error("Bundled executor binary is not available."); | |
| 111 | + } | |
| 112 | + const result = await runExecutor(["install", "--port", String(opts.port)], { | |
| 113 | + dataDir: opts.dataDir, | |
| 204 | 114 | }); |
| 205 | − writeFileSync(plistPath(), plist, { mode: 0o600 }); | |
| 206 | − | |
| 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 | − ); | |
| 115 | + if (result.code !== 0) { | |
| 116 | + throw new Error((result.stderr || result.stdout).trim() || "`executor install` failed."); | |
| 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 | 121 | export const uninstallSupervisedService = async (dataDir: string): Promise<void> => { |
| 224 | − const uid = currentUid(); | |
| 225 | − await runCommand("launchctl", ["bootout", serviceTarget(uid)]); | |
| 226 | − await runCommand("launchctl", ["disable", serviceTarget(uid)]); | |
| 227 | − rmSync(plistPath(), { force: true }); | |
| 228 | − // Clean up a legacy service.key from a pre-bearer install (best-effort). | |
| 229 | − rmSync(join(dataDir, "server-control", "service.key"), { force: true }); | |
| 230 | − serviceLog.info("uninstalled supervised service"); | |
| 122 | + if (!executorAvailable()) return; | |
| 123 | + const result = await runExecutor(["service", "uninstall"], { dataDir }); | |
| 124 | + if (result.code !== 0) { | |
| 125 | + throw new Error( | |
| 126 | + (result.stderr || result.stdout).trim() || "`executor service uninstall` failed.", | |
| 127 | + ); | |
| 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 | 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 | 1 | import Store from "electron-store"; |
| 2 | −import { rotateLocalAuthToken } from "@executor-js/local/auth"; | |
| 2 | +import { rotateLocalAuthToken } from "./local-auth"; | |
| 3 | 3 | import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; |
| 4 | 4 | |
| 5 | 5 | interface PersistedShape { |
| @@ -9,6 +9,9 @@ interface PersistedShape { · 3 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | const store = new Store<PersistedShape>({ |
| 11 | 11 | name: "settings", |
| 12 | + ...(process.env.EXECUTOR_DESKTOP_SETTINGS_DIR | |
| 13 | + ? { cwd: process.env.EXECUTOR_DESKTOP_SETTINGS_DIR } | |
| 14 | + : {}), | |
| 12 | 15 | defaults: { server: DEFAULT_SERVER_SETTINGS }, |
| 13 | 16 | }); |
| 14 | 17 | |
| @@ -2,13 +2,11 @@ · 1 unmodified line | ||
| 2 | 2 | * Sidecar lifecycle manager run inside the Electron main process. |
| 3 | 3 | * |
| 4 | 4 | * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`. |
| 5 | − * In prod: spawns the Bun-compiled `executor-sidecar` binary shipped under | |
| 6 | − * `process.resourcesPath/sidecar/`. | |
| 5 | + * In prod: spawns the bundled CLI binary in foreground daemon mode. | |
| 7 | 6 | * |
| 8 | 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 | |
| 10 | − * single sentinel line on stdout (`EXECUTOR_READY:<port>`) so this controller | |
| 11 | − * can resolve the connection promise. | |
| 8 | + * The dev sidecar announces `EXECUTOR_READY:<port>`. The packaged CLI daemon | |
| 9 | + * announces `Daemon ready on http://host:port`. This controller accepts both. | |
| 12 | 10 | */ |
| 13 | 11 | |
| 14 | 12 | import { spawn, type ChildProcess } from "node:child_process"; |
| @@ -23,7 +21,7 @@ import { · 8 unmodified lines | ||
| 23 | 21 | parseExecutorLocalServerManifest, |
| 24 | 22 | serializeExecutorLocalServerManifest, |
| 25 | 23 | } from "@executor-js/sdk/shared"; |
| 26 | −import { loadOrMintLocalAuthToken } from "@executor-js/local/auth"; | |
| 24 | +import { loadOrMintLocalAuthToken } from "./local-auth"; | |
| 27 | 25 | import { getServerSettings } from "./settings"; |
| 28 | 26 | import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; |
| 29 | 27 | import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; |
| @@ -79,6 +77,9 @@ export interface SidecarConnection { · 49 unmodified lines | ||
| 79 | 77 | * leave MCP serving. |
| 80 | 78 | */ |
| 81 | 79 | readonly supervisedDaemon: boolean; |
| 80 | + readonly ownerVersion: string | null; | |
| 81 | + readonly ownerClient: "cli" | "desktop"; | |
| 82 | + readonly ownerExecutablePath: string | null; | |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | export class SidecarPortInUseError extends Error { |
| @@ -217,40 +218,48 @@ const writeSidecarManifest = (input: { · 132 unmodified lines | ||
| 217 | 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 | 226 | if (app.isPackaged) { |
| 222 | − const binaryName = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; | |
| 223 | − const binaryPath = join(process.resourcesPath, "sidecar", binaryName); | |
| 224 | − return { command: binaryPath, args: [], cwd: process.resourcesPath }; | |
| 227 | + const binaryName = process.platform === "win32" ? "executor.exe" : "executor"; | |
| 228 | + const binaryPath = join(process.resourcesPath, "executor", binaryName); | |
| 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 | 246 | // Dev: run the TS source directly via bun on PATH. |
| 227 | 247 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); |
| 228 | 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 | 252 | const resolveClientDir = (): string => { |
| 233 | − if (app.isPackaged) { | |
| 234 | − return join(process.resourcesPath, "web-ui"); | |
| 235 | − } | |
| 236 | 253 | const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); |
| 237 | 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 | 260 | export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { |
| 241 | 261 | const hostname = options.hostname ?? "127.0.0.1"; |
| 242 | 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 | 263 | // data.db and the optional executor.jsonc plugin manifest live under |
| 255 | 264 | // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI |
| 256 | 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 | 276 | // process holds it so it can inject the header into the webview; the child |
| 268 | 277 | // validates against the same value. Always present — auth is unconditional. |
| 269 | 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 | 294 | let startupLockReleased = false; |
| 272 | 295 | const releaseLock = () => { |
| 273 | 296 | if (startupLockReleased) return; |
| 274 | 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 | |
| 279 | − try { | |
| 280 | − assertNoOtherLocalServerOwner(dataDir); | |
| 281 | − } catch (error) { | |
| 282 | − releaseLock(); | |
| 283 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock | |
| 284 | − throw error; | |
| 301 | + if (!cliManagedManifest) { | |
| 302 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures | |
| 303 | + try { | |
| 304 | + assertNoOtherLocalServerOwner(dataDir); | |
| 305 | + } catch (error) { | |
| 306 | + releaseLock(); | |
| 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 | 312 | let child: ChildProcess; |
| @@ -300,7 +325,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 12 unmodified lines | ||
| 300 | 325 | // The bearer token the child validates and the main process injects into |
| 301 | 326 | // the webview. Always set — auth is unconditional. |
| 302 | 327 | EXECUTOR_AUTH_TOKEN: authToken, |
| 303 | − EXECUTOR_CLIENT_DIR: clientDir, | |
| 328 | + ...(clientDir ? { EXECUTOR_CLIENT_DIR: clientDir } : {}), | |
| 304 | 329 | EXECUTOR_SCOPE_DIR: scopeDir, |
| 305 | 330 | EXECUTOR_DATA_DIR: dataDir, |
| 306 | 331 | EXECUTOR_CLIENT: "desktop", |
| @@ -334,9 +359,11 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 27 unmodified lines | ||
| 334 | 359 | |
| 335 | 360 | const onStdout = (chunk: Buffer) => { |
| 336 | 361 | const text = chunk.toString("utf8"); |
| 337 | − process.stdout.write(`[executor-sidecar] ${text}`); | |
| 362 | + process.stdout.write(`[executor-server] ${text}`); | |
| 338 | 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 | 367 | if (match && !resolved) { |
| 341 | 368 | if (!child.pid) { |
| 342 | 369 | reject( |
| @@ -348,13 +375,15 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines | ||
| 348 | 375 | resolved = true; |
| 349 | 376 | const port = parseInt(match[1], 10); |
| 350 | 377 | const baseUrl = `http://${hostname}:${port}`; |
| 351 | − writeSidecarManifest({ | |
| 352 | − dataDir, | |
| 353 | − scopeDir, | |
| 354 | − baseUrl, | |
| 355 | − authToken, | |
| 356 | − childPid: child.pid, | |
| 357 | − }); | |
| 378 | + if (!cliManagedManifest) { | |
| 379 | + writeSidecarManifest({ | |
| 380 | + dataDir, | |
| 381 | + scopeDir, | |
| 382 | + baseUrl, | |
| 383 | + authToken, | |
| 384 | + childPid: child.pid, | |
| 385 | + }); | |
| 386 | + } | |
| 358 | 387 | releaseLock(); |
| 359 | 388 | resolveStart({ |
| 360 | 389 | baseUrl, |
| @@ -364,6 +393,9 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 3 unmodified lines | ||
| 364 | 393 | authToken, |
| 365 | 394 | child, |
| 366 | 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 | 403 | const onStderr = (chunk: Buffer) => { |
| 372 | 404 | const text = chunk.toString("utf8"); |
| 373 | 405 | stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT); |
| 374 | − process.stderr.write(`[executor-sidecar] ${text}`); | |
| 406 | + process.stderr.write(`[executor-server] ${text}`); | |
| 375 | 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 | 443 | /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */ |
| 412 | 444 | const isDaemonReachable = async (origin: string): Promise<boolean> => { |
| 413 | − const controller = new AbortController(); | |
| 414 | − const timer = setTimeout(() => controller.abort(), 1500); | |
| 415 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal | |
| 416 | − try { | |
| 417 | − const response = await fetch(new URL("/api/health", origin), { | |
| 418 | − signal: controller.signal, | |
| 419 | − redirect: "manual", | |
| 420 | − }); | |
| 421 | − const body = await response.text(); | |
| 422 | − return response.ok && body.trim() === "ok"; | |
| 423 | − } catch { | |
| 424 | − return false; | |
| 425 | − } finally { | |
| 426 | − clearTimeout(timer); | |
| 445 | + for (let attempt = 0; attempt < 3; attempt += 1) { | |
| 446 | + const controller = new AbortController(); | |
| 447 | + const timer = setTimeout(() => controller.abort(), 1500); | |
| 448 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal | |
| 449 | + try { | |
| 450 | + const response = await fetch(new URL("/api/health", origin), { | |
| 451 | + signal: controller.signal, | |
| 452 | + redirect: "manual", | |
| 453 | + }); | |
| 454 | + const body = await response.text(); | |
| 455 | + if (response.ok && body.trim() === "ok") return true; | |
| 456 | + } catch { | |
| 457 | + // retry below | |
| 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 | 486 | const origin = manifest.connection.origin; |
| 451 | 487 | const auth = manifest.connection.auth; |
| 452 | 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 | 495 | const url = new URL(origin); |
| 456 | 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 | 502 | authToken, |
| 463 | 503 | child: null, |
| 464 | 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 | 6 | * renderer (Settings UI + Connect-an-agent surface) need to agree on it. |
| 7 | 7 | * |
| 8 | 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 | 10 | * the webview transparently, so the renderer never sees the credential and |
| 11 | 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 | 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native |
| 5 | 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 | |
| 7 | −// the executable (`libsql.node`, `keyring.node`); here we publish their | |
| 6 | +// the binary fails. If this dev sidecar is compiled directly, copy each | |
| 7 | +// platform's `.node` next to the executable (`libsql.node`, `keyring.node`); | |
| 8 | +// here we publish their | |
| 8 | 9 | // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. |
| 9 | 10 | // |
| 10 | 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 | |
| 3 | − * process (either via `bun run ...` in dev or as a Bun-compiled binary in | |
| 4 | − * production). | |
| 2 | + * Bun-side sidecar entry. Spawned by the Electron main process in dev via | |
| 3 | + * `bun run ...`. Packaged desktop uses the bundled `executor` CLI binary | |
| 4 | + * instead. | |
| 5 | 5 | * |
| 6 | 6 | * Reads connection parameters from env, boots the executor server, then |
| 7 | 7 | * announces readiness with the resolved port on stdout so the Electron |
| @@ -12,11 +12,9 @@ · 4 unmodified lines | ||
| 12 | 12 | import "./native-bindings"; |
| 13 | 13 | import { dirname, join } from "node:path"; |
| 14 | 14 | |
| 15 | −// Pre-load QuickJS WASM for compiled binaries. `bun build --compile` can't | |
| 16 | −// embed the side-asset WASM that `quickjs-emscripten` ships with, so | |
| 17 | −// build-sidecar.ts stages it next to this binary and we feed the bytes in | |
| 18 | −// via `setQuickJSModule` before any server import touches QuickJS. Mirrors | |
| 19 | −// the CLI's preload in apps/cli/src/main.ts. | |
| 15 | +// Pre-load QuickJS WASM for manually compiled sidecar binaries. Packaged | |
| 16 | +// desktop no longer ships this entrypoint, but keeping the preload here lets | |
| 17 | +// direct sidecar smoke/debug runs behave like the CLI binary. | |
| 20 | 18 | const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm"); |
| 21 | 19 | if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { |
| 22 | 20 | const { setQuickJSModule } = await import("@executor-js/runtime-quickjs"); |
| @@ -30,6 +30,11 @@ import { · 29 unmodified lines | ||
| 30 | 30 | |
| 31 | 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 | 38 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { |
| 34 | 39 | const routes: Record<string, StaticHandler> = {}; |
| 35 | 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 | 47 | } else { |
| 43 | 48 | const file = Bun.file(fullPath); |
| 44 | 49 | routes[routePath] = () => |
| 45 | − new Response(file, { | |
| 46 | − headers: { "content-type": file.type || "application/octet-stream" }, | |
| 47 | − }); | |
| 50 | + routePath === "/index.html" | |
| 51 | + ? htmlResponse(file) | |
| 52 | + : new Response(file, { | |
| 53 | + headers: { "content-type": file.type || "application/octet-stream" }, | |
| 54 | + }); | |
| 48 | 55 | } |
| 49 | 56 | } |
| 50 | 57 | } catch {} |
| @@ -60,9 +67,11 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 9 unmodified lines | ||
| 60 | 67 | for (const [key, bunfsPath] of Object.entries(embedded)) { |
| 61 | 68 | const file = Bun.file(bunfsPath); |
| 62 | 69 | routes[`/${key}`] = () => |
| 63 | − new Response(file, { | |
| 64 | − headers: { "content-type": file.type || "application/octet-stream" }, | |
| 65 | − }); | |
| 70 | + key === "index.html" | |
| 71 | + ? htmlResponse(file) | |
| 72 | + : new Response(file, { | |
| 73 | + headers: { "content-type": file.type || "application/octet-stream" }, | |
| 74 | + }); | |
| 66 | 75 | } |
| 67 | 76 | return routes; |
| 68 | 77 | } |
| @@ -300,11 +309,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 231 unmodified lines | ||
| 300 | 309 | } else if (opts.embeddedWebUI) { |
| 301 | 310 | staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); |
| 302 | 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 | 313 | } else { |
| 305 | 314 | staticRoutes = collectStaticRoutes(clientDir); |
| 306 | 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 | 319 | const server = Bun.serve({ |
| @@ -42,6 +42,7 @@ · 41 unmodified lines | ||
| 42 | 42 | "@executor-js/runtime-quickjs": "workspace:*", |
| 43 | 43 | "@executor-js/sdk": "workspace:*", |
| 44 | 44 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 45 | + "@sentry/bun": "^10.57.0", | |
| 45 | 46 | "effect": "catalog:", |
| 46 | 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 | 4 | // ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the |
| 5 | 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 | |
| 8 | −// binary a supervised install runs) in EXECUTOR_SUPERVISED mode → it self- | |
| 9 | −// publishes a manifest of kind "cli-daemon". Then we launch the packaged app | |
| 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 publishes a | |
| 9 | +// manifest of kind "cli-daemon". Then we launch the packaged app | |
| 10 | 10 | // pointed at the same HOME and prove it attached: the manifest still names the |
| 11 | 11 | // daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a |
| 12 | 12 | // fresh pid), and the console — served by the bearer-gated daemon — renders, |
| 13 | 13 | // which only happens if the app injected the bearer it read from the manifest. |
| 14 | 14 | // The recording (session.mp4 + screenshots) is the artifact; the waits assert. |
| 15 | −import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; | |
| 16 | −import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 15 | +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; | |
| 16 | +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 17 | 17 | import net from "node:net"; |
| 18 | 18 | import { tmpdir } from "node:os"; |
| 19 | −import { dirname, join } from "node:path"; | |
| 20 | −import { promisify } from "node:util"; | |
| 19 | +import { join } from "node:path"; | |
| 21 | 20 | |
| 22 | 21 | import { expect, it } from "@effect/vitest"; |
| 23 | 22 | import { Effect } from "effect"; |
| 24 | −import { _electron } from "playwright"; | |
| 25 | 23 | |
| 26 | 24 | import { scenario } from "../src/scenario"; |
| 27 | 25 | import { RunDir } from "../src/services"; |
| @@ -50,10 +48,138 @@ const guiAvailable = (): boolean => { · 22 unmodified lines | ||
| 50 | 48 | const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; |
| 51 | 49 | |
| 52 | 50 | const appExe = process.env.E2E_DESKTOP_APP_EXE; |
| 53 | −const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; | |
| 54 | −// The bundled web UI sits beside the sidecar in Resources/ (…/sidecar/<bin> → | |
| 55 | −// …/web-ui). The compiled sidecar serves it via EXECUTOR_CLIENT_DIR. | |
| 56 | −const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; | |
| 51 | +const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; | |
| 52 | + | |
| 53 | +interface PackagedApp { | |
| 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 | 184 | const freePort = (): Promise<number> => |
| 59 | 185 | new Promise((resolve, reject) => { |
| @@ -76,16 +202,20 @@ interface DaemonStart { · 16 unmodified lines | ||
| 76 | 202 | readonly stderr: string; |
| 77 | 203 | } |
| 78 | 204 | |
| 79 | −/** Spawn the bundle's compiled sidecar as a supervised daemon; resolves once it | |
| 80 | − * announces EXECUTOR_READY (or times out / exits early, ready:false). */ | |
| 81 | −const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => | |
| 205 | +/** Spawn the bundle's compiled executor as a supervised daemon; resolves once it | |
| 206 | + * announces readiness (or times out / exits early, ready:false). */ | |
| 207 | +const startSupervisedDaemon = (env: NodeJS.ProcessEnv, port: number): Promise<DaemonStart> => | |
| 82 | 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 | 214 | let stderr = ""; |
| 85 | 215 | const settle = (ready: boolean) => resolve({ child, ready, stderr }); |
| 86 | 216 | const timer = setTimeout(() => settle(false), 60_000); |
| 87 | 217 | child.stdout.on("data", (chunk: Buffer) => { |
| 88 | − if (chunk.toString().includes("EXECUTOR_READY:")) { | |
| 218 | + if (/Daemon ready on http:\/\//.test(chunk.toString())) { | |
| 89 | 219 | clearTimeout(timer); |
| 90 | 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()) { | |
| 103 | − it.skip(`${SCENARIO_NAME} (needs a GUI display — Aqua / X / Wayland)`, () => {}); | |
| 232 | +const packagedSingleInstanceAvailable = (): boolean => { | |
| 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 | 335 | } else { |
| 105 | 336 | scenario( |
| 106 | 337 | SCENARIO_NAME, |
| 107 | 338 | { timeout: 240_000 }, |
| 108 | 339 | Effect.gen(function* () { |
| 109 | − if (!appExe || !sidecarBin) { | |
| 340 | + if (!appExe || !executorBin) { | |
| 110 | 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 | 345 | const runDir = yield* RunDir; |
| @@ -121,58 +352,50 @@ const run = async (runDir: string) => { · 6 unmodified lines | ||
| 121 | 352 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-")); |
| 122 | 353 | const dataDir = join(home, ".executor"); |
| 123 | 354 | const manifestPath = join(dataDir, "server-control", "server.json"); |
| 124 | − const videoTmp = join(runDir, ".video-tmp"); | |
| 125 | 355 | const port = await freePort(); |
| 126 | 356 | |
| 127 | 357 | let daemon: ChildProcess | undefined; |
| 128 | − let app: Awaited<ReturnType<typeof _electron.launch>> | undefined; | |
| 358 | + let app: PackagedApp | undefined; | |
| 129 | 359 | let stepIndex = 0; |
| 130 | 360 | |
| 131 | 361 | try { |
| 132 | − const started = await startSupervisedDaemon({ | |
| 133 | − ...process.env, | |
| 134 | − HOME: home, | |
| 135 | − EXECUTOR_SUPERVISED: "1", | |
| 136 | − EXECUTOR_DATA_DIR: dataDir, | |
| 137 | − EXECUTOR_PORT: String(port), | |
| 138 | − EXECUTOR_HOST: "127.0.0.1", | |
| 139 | − EXECUTOR_AUTH_TOKEN: "packaged-attach-film", | |
| 140 | − EXECUTOR_CLIENT_DIR: clientDir, | |
| 141 | − }); | |
| 362 | + const started = await startSupervisedDaemon( | |
| 363 | + { | |
| 364 | + ...process.env, | |
| 365 | + HOME: home, | |
| 366 | + EXECUTOR_SUPERVISED: "1", | |
| 367 | + EXECUTOR_DATA_DIR: dataDir, | |
| 368 | + EXECUTOR_AUTH_TOKEN: "packaged-attach-film", | |
| 369 | + EXECUTOR_CLIENT: "desktop", | |
| 370 | + }, | |
| 371 | + port, | |
| 372 | + ); | |
| 142 | 373 | daemon = started.child; |
| 143 | 374 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); |
| 144 | 375 | await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); |
| 145 | 376 | |
| 146 | 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 | 379 | "cli-daemon", |
| 149 | 380 | ); |
| 150 | 381 | const daemonPid = daemonManifest.pid; |
| 151 | 382 | |
| 152 | − // Launch the PACKAGED bundle (executablePath = the installed app binary, no | |
| 153 | − // app-dir arg) → app.isPackaged is true → boot() runs the supervised attach. | |
| 154 | − app = await _electron.launch({ | |
| 155 | − executablePath: appExe as string, | |
| 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 }); | |
| 383 | + // Launch the PACKAGED bundle directly. `app.isPackaged` is true, so boot() | |
| 384 | + // runs the supervised attach path; CDP drives the real renderer. | |
| 385 | + app = await launchPackaged(home); | |
| 386 | + const page = app.cdp; | |
| 162 | 387 | const step = async (label: string, body: () => Promise<void>) => { |
| 163 | 388 | await body(); |
| 164 | 389 | stepIndex += 1; |
| 165 | 390 | const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); |
| 166 | − await page.screenshot({ | |
| 167 | − path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), | |
| 168 | − }); | |
| 391 | + await page.screenshot(join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`)); | |
| 169 | 392 | }; |
| 170 | 393 | |
| 171 | 394 | // The console only renders once the app has a live connection AND the bearer |
| 172 | 395 | // it injects is accepted by the gated daemon — so reaching it proves both the |
| 173 | 396 | // attach and the bearer wiring through the packaged session layer. |
| 174 | 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 | 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 | 410 | } finally { |
| 188 | − const page = app?.windows()[0]; | |
| 189 | − const video = page?.video(); | |
| 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 }); | |
| 411 | + await closePackaged(app); | |
| 412 | + await stopProcess(daemon); | |
| 212 | 413 | rmSync(home, { recursive: true, force: true }); |
| 213 | 414 | } |
| 214 | 415 | }; |
| @@ -1,17 +1,25 @@ | ||
| 1 | 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 | 3 | // path is production-only (`app.isPackaged`). |
| 4 | 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 | 15 | import { createServer, type IncomingMessage } from "node:http"; |
| 7 | 16 | import net from "node:net"; |
| 8 | −import { tmpdir } from "node:os"; | |
| 17 | +import { homedir, tmpdir } from "node:os"; | |
| 9 | 18 | import { dirname, join } from "node:path"; |
| 10 | 19 | import { promisify } from "node:util"; |
| 11 | 20 | |
| 12 | 21 | import { expect, it } from "@effect/vitest"; |
| 13 | 22 | import { Effect } from "effect"; |
| 14 | −import { _electron, type ElectronApplication } from "playwright"; | |
| 15 | 23 | import { |
| 16 | 24 | normalizeExecutorServerConnection, |
| 17 | 25 | serializeExecutorLocalServerManifest, |
| @@ -21,6 +29,9 @@ import { scenario } from "../src/scenario"; · 3 unmodified lines | ||
| 21 | 29 | import { RunDir } from "../src/services"; |
| 22 | 30 | import { waitForHttp } from "../setup/boot"; |
| 23 | 31 | |
| 32 | +const execFileAsync = promisify(execFile); | |
| 33 | +const SERVICE_LABEL = "sh.executor.daemon"; | |
| 34 | + | |
| 24 | 35 | interface PackagedExecutorBridge { |
| 25 | 36 | readonly getSettings: () => Promise<{ readonly port: number }>; |
| 26 | 37 | readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>; |
| @@ -28,6 +39,173 @@ interface PackagedExecutorBridge { · 1 unmodified line | ||
| 28 | 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 | 209 | declare global { |
| 32 | 210 | interface Window { |
| 33 | 211 | readonly executor: PackagedExecutorBridge; |
| @@ -35,8 +213,7 @@ declare global { · 1 unmodified line | ||
| 35 | 213 | } |
| 36 | 214 | |
| 37 | 215 | const appExe = process.env.E2E_DESKTOP_APP_EXE; |
| 38 | −const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; | |
| 39 | −const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; | |
| 216 | +const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; | |
| 40 | 217 | |
| 41 | 218 | const guiAvailable = (): boolean => { |
| 42 | 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 } => { | |
| 69 | − if (!appExe || !sidecarBin) { | |
| 245 | +const requireBundle = (): { readonly app: string; readonly executor: string } => { | |
| 246 | + if (!appExe || !executorBin) { | |
| 70 | 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 | 343 | const freePort = (): Promise<number> => |
| @@ -90,10 +356,18 @@ interface DaemonStart { · 12 unmodified lines | ||
| 90 | 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 | 364 | new Promise((resolve) => { |
| 95 | − const { sidecar } = requireBundle(); | |
| 96 | − const child = spawn(sidecar, [], { env, stdio: ["ignore", "pipe", "pipe"] }); | |
| 365 | + const { executor } = requireBundle(); | |
| 366 | + const child = spawn( | |
| 367 | + executor, | |
| 368 | + ["daemon", "run", "--foreground", "--port", String(port), "--hostname", hostname], | |
| 369 | + { env, stdio: ["ignore", "pipe", "pipe"] }, | |
| 370 | + ); | |
| 97 | 371 | let stderr = ""; |
| 98 | 372 | let settled = false; |
| 99 | 373 | const settle = (ready: boolean) => { |
| @@ -103,7 +377,7 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 3 unmodified lines | ||
| 103 | 377 | }; |
| 104 | 378 | const timer = setTimeout(() => settle(false), 60_000); |
| 105 | 379 | child.stdout.on("data", (chunk: Buffer) => { |
| 106 | − if (chunk.toString().includes("EXECUTOR_READY:")) { | |
| 380 | + if (/Daemon ready on http:\/\//.test(chunk.toString())) { | |
| 107 | 381 | clearTimeout(timer); |
| 108 | 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 ( | |
| 121 | − app: ElectronApplication | undefined, | |
| 122 | − runDir: string, | |
| 123 | − videoTmp: string, | |
| 124 | −) => { | |
| 125 | − const page = app?.windows()[0]; | |
| 126 | − const video = page?.video(); | |
| 127 | − await app?.close().catch(() => {}); | |
| 128 | − const recordedPath = await video?.path().catch(() => undefined); | |
| 129 | − if (recordedPath) { | |
| 130 | − await promisify(execFile)("ffmpeg", [ | |
| 131 | − "-y", | |
| 132 | − "-i", | |
| 133 | − recordedPath, | |
| 134 | − "-c:v", | |
| 135 | − "libx264", | |
| 136 | − "-preset", | |
| 137 | − "veryfast", | |
| 138 | − "-crf", | |
| 139 | − "26", | |
| 140 | − "-pix_fmt", | |
| 141 | − "yuv420p", | |
| 142 | − "-movflags", | |
| 143 | − "+faststart", | |
| 144 | − join(runDir, "session.mp4"), | |
| 145 | − ]).catch(() => {}); | |
| 394 | +const stopProcess = async (child: ChildProcess | undefined): Promise<void> => { | |
| 395 | + if (!child || child.exitCode !== null || child.signalCode !== null) return; | |
| 396 | + await new Promise<void>((resolve) => { | |
| 397 | + const timeout = setTimeout(() => { | |
| 398 | + child.kill("SIGKILL"); | |
| 399 | + resolve(); | |
| 400 | + }, 5_000); | |
| 401 | + child.once("exit", () => { | |
| 402 | + clearTimeout(timeout); | |
| 403 | + resolve(); | |
| 404 | + }); | |
| 405 | + child.kill("SIGTERM"); | |
| 406 | + }); | |
| 407 | +}; | |
| 408 | + | |
| 409 | +const waitForPageWebSocket = async (debugPort: string): Promise<string> => { | |
| 410 | + const deadline = Date.now() + 120_000; | |
| 411 | + for (;;) { | |
| 412 | + const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`) | |
| 413 | + .then((response) => (response.ok ? response.json() : [])) | |
| 414 | + .catch(() => [])) as ReadonlyArray<CdpTarget>; | |
| 415 | + const page = targets.find( | |
| 416 | + (target) => | |
| 417 | + target.type === "page" && | |
| 418 | + target.webSocketDebuggerUrl && | |
| 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 | 618 | scenario( |
| @@ -159,16 +627,17 @@ scenario( · 8 unmodified lines | ||
| 159 | 627 | let daemon: ChildProcess | undefined; |
| 160 | 628 | const previousUmask = process.umask(0o022); |
| 161 | 629 | try { |
| 162 | − const started = await startSupervisedDaemon({ | |
| 163 | − ...process.env, | |
| 164 | − HOME: home, | |
| 165 | − EXECUTOR_SUPERVISED: "1", | |
| 166 | − EXECUTOR_DATA_DIR: dataDir, | |
| 167 | − EXECUTOR_PORT: String(port), | |
| 168 | − EXECUTOR_HOST: "127.0.0.1", | |
| 169 | − EXECUTOR_AUTH_TOKEN: "manifest-mode-token", | |
| 170 | − EXECUTOR_CLIENT_DIR: clientDir, | |
| 171 | − }); | |
| 630 | + const started = await startSupervisedDaemon( | |
| 631 | + { | |
| 632 | + ...process.env, | |
| 633 | + HOME: home, | |
| 634 | + EXECUTOR_SUPERVISED: "1", | |
| 635 | + EXECUTOR_DATA_DIR: dataDir, | |
| 636 | + EXECUTOR_AUTH_TOKEN: "manifest-mode-token", | |
| 637 | + EXECUTOR_CLIENT: "desktop", | |
| 638 | + }, | |
| 639 | + port, | |
| 640 | + ); | |
| 172 | 641 | daemon = started.child; |
| 173 | 642 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe( |
| 174 | 643 | true, |
| @@ -194,10 +663,7 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 19 unmodified lines | ||
| 194 | 663 | scenario( |
| 195 | 664 | "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", |
| 196 | 665 | { timeout: 240_000 }, |
| 197 | − Effect.gen(function* () { | |
| 198 | − const runDir = yield* RunDir; | |
| 199 | − yield* Effect.promise(() => runStaleManifestProbe(runDir)); | |
| 200 | − }), | |
| 666 | + Effect.promise(() => runStaleManifestProbe()), | |
| 201 | 667 | ); |
| 202 | 668 | |
| 203 | 669 | scenario( |
| @@ -208,24 +674,24 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 4 unmodified lines | ||
| 208 | 674 | yield* Effect.promise(() => runSupervisedPortSetting(runDir)); |
| 209 | 675 | }), |
| 210 | 676 | ); |
| 211 | −} | |
| 212 | 677 | |
| 213 | −const launchPackaged = (home: string, videoTmp: string): Promise<ElectronApplication> => { | |
| 214 | − const { app } = requireBundle(); | |
| 215 | − return _electron.launch({ | |
| 216 | − executablePath: app, | |
| 217 | − env: { ...process.env, HOME: home }, | |
| 218 | − recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, | |
| 219 | − timeout: 120_000, | |
| 220 | − }); | |
| 221 | −}; | |
| 678 | + scenario( | |
| 679 | + "Desktop packaged supervised attach · integrations load through the CLI daemon with stale profiles", | |
| 680 | + { timeout: 240_000 }, | |
| 681 | + Effect.gen(function* () { | |
| 682 | + const runDir = yield* RunDir; | |
| 683 | + yield* Effect.promise(() => runSupervisedIntegrationsLoad(runDir)); | |
| 684 | + }), | |
| 685 | + ); | |
| 686 | +} | |
| 222 | 687 | |
| 223 | −const runStaleManifestProbe = async (runDir: string) => { | |
| 688 | +const runStaleManifestProbe = async () => { | |
| 224 | 689 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); |
| 225 | 690 | const dataDir = join(home, ".executor"); |
| 226 | 691 | const controlDir = join(dataDir, "server-control"); |
| 227 | − const videoTmp = join(runDir, ".video-tmp"); | |
| 692 | + const manifestPath = join(controlDir, "server.json"); | |
| 228 | 693 | const token = "stale-manifest-leaked-token"; |
| 694 | + const launchdSnapshot = captureLaunchdService(); | |
| 229 | 695 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; |
| 230 | 696 | let resolveFirst!: () => void; |
| 231 | 697 | const firstRequest = new Promise<void>((resolve) => { |
| @@ -238,11 +704,12 @@ const runStaleManifestProbe = async (runDir: string) => { · 6 unmodified lines | ||
| 238 | 704 | }); |
| 239 | 705 | resolveFirst(); |
| 240 | 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 | 709 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 244 | 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 | 714 | try { |
| 248 | 715 | mkdirSync(controlDir, { recursive: true }); |
| @@ -265,19 +732,35 @@ const runStaleManifestProbe = async (runDir: string) => { · 16 unmodified lines | ||
| 265 | 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 | 745 | const probed = await Promise.race([ |
| 270 | 746 | firstRequest.then(() => true), |
| 271 | 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 | 751 | expect( |
| 276 | 752 | requests[0]?.authorization ?? null, |
| 277 | 753 | "the stale-manifest reachability probe must not disclose the saved bearer", |
| 278 | 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 | 761 | } finally { |
| 280 | − await closeWithVideo(app, runDir, videoTmp); | |
| 762 | + await stopProcess(appProcess); | |
| 763 | + await restoreLaunchdService(launchdSnapshot); | |
| 281 | 764 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 282 | 765 | rmSync(home, { recursive: true, force: true }); |
| 283 | 766 | } |
| @@ -286,64 +769,166 @@ const runStaleManifestProbe = async (runDir: string) => { · 2 unmodified lines | ||
| 286 | 769 | const runSupervisedPortSetting = async (runDir: string) => { |
| 287 | 770 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-")); |
| 288 | 771 | const dataDir = join(home, ".executor"); |
| 289 | − const videoTmp = join(runDir, ".video-tmp"); | |
| 772 | + const launchdSnapshot = captureLaunchdService(); | |
| 290 | 773 | const oldPort = await freePort(); |
| 291 | 774 | const newPort = await freePort(); |
| 292 | 775 | let daemon: ChildProcess | undefined; |
| 293 | − let app: ElectronApplication | undefined; | |
| 776 | + let app: PackagedApp | undefined; | |
| 294 | 777 | |
| 295 | 778 | try { |
| 296 | − const started = await startSupervisedDaemon({ | |
| 297 | − ...process.env, | |
| 298 | − HOME: home, | |
| 299 | − EXECUTOR_SUPERVISED: "1", | |
| 300 | − EXECUTOR_DATA_DIR: dataDir, | |
| 301 | − EXECUTOR_PORT: String(oldPort), | |
| 302 | − EXECUTOR_HOST: "127.0.0.1", | |
| 303 | − EXECUTOR_AUTH_TOKEN: "port-setting-token", | |
| 304 | − EXECUTOR_CLIENT_DIR: clientDir, | |
| 305 | − }); | |
| 779 | + const started = await startSupervisedDaemon( | |
| 780 | + { | |
| 781 | + ...process.env, | |
| 782 | + HOME: home, | |
| 783 | + EXECUTOR_SUPERVISED: "1", | |
| 784 | + EXECUTOR_DATA_DIR: dataDir, | |
| 785 | + EXECUTOR_AUTH_TOKEN: "port-setting-token", | |
| 786 | + EXECUTOR_CLIENT: "desktop", | |
| 787 | + }, | |
| 788 | + oldPort, | |
| 789 | + ); | |
| 306 | 790 | daemon = started.child; |
| 307 | 791 | expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); |
| 308 | 792 | await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 }); |
| 309 | 793 | |
| 310 | − app = await launchPackaged(home, videoTmp); | |
| 311 | − const page = await app.firstWindow({ timeout: 120_000 }); | |
| 312 | − await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); | |
| 794 | + app = await launchPackaged(home); | |
| 795 | + let page = app.cdp; | |
| 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 () => { | |
| 315 | − return window.executor.getServerConnection(); | |
| 316 | − }); | |
| 801 | + const before = await page.evaluate<{ readonly origin: string } | null>( | |
| 802 | + "window.executor.getServerConnection()", | |
| 803 | + ); | |
| 317 | 804 | expect(new URL(before!.origin).port, "test starts attached to the original port").toBe( |
| 318 | 805 | String(oldPort), |
| 319 | 806 | ); |
| 320 | 807 | |
| 321 | − await page.evaluate(async (port) => { | |
| 322 | − await window.executor.updateSettings({ port }); | |
| 323 | − }, newPort); | |
| 808 | + await page.evaluate(`window.executor.updateSettings({ port: ${JSON.stringify(newPort)} })`); | |
| 324 | 809 | |
| 325 | 810 | await page |
| 326 | − .evaluate(async () => { | |
| 327 | − await window.executor.restartServer(); | |
| 328 | − }) | |
| 811 | + .evaluate("window.executor.restartServer().catch(() => undefined)") | |
| 329 | 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 () => { | |
| 333 | − return { | |
| 334 | − settings: await window.executor.getSettings(), | |
| 335 | − connection: await window.executor.getServerConnection(), | |
| 336 | − }; | |
| 337 | − }); | |
| 816 | + const after = await page.evaluate<{ | |
| 817 | + readonly settings: { readonly port: number }; | |
| 818 | + readonly connection: { readonly origin: string } | null; | |
| 819 | + }>( | |
| 820 | + "(async () => ({ settings: await window.executor.getSettings(), connection: await window.executor.getServerConnection() }))()", | |
| 821 | + ); | |
| 338 | 822 | |
| 339 | 823 | expect(after.settings.port, "the setting was persisted").toBe(newPort); |
| 340 | 824 | expect( |
| 341 | 825 | new URL(after.connection!.origin).port, |
| 342 | 826 | "after restart, the active supervised daemon should be serving on the saved port", |
| 343 | 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 | 928 | } finally { |
| 345 | − await closeWithVideo(app, runDir, videoTmp); | |
| 346 | − daemon?.kill("SIGTERM"); | |
| 929 | + await closePackaged(app); | |
| 930 | + await stopProcess(daemon); | |
| 931 | + await restoreLaunchdService(launchdSnapshot); | |
| 347 | 932 | rmSync(home, { recursive: true, force: true }); |
| 348 | 933 | } |
| 349 | 934 | }; |
| @@ -2,12 +2,12 @@ · 1 unmodified line | ||
| 2 | 2 | // dev electron) so the scenarios drive the production artifact — the only place |
| 3 | 3 | // app.isPackaged is true, which is what gates the supervised-daemon attach path |
| 4 | 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 | 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 | 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 | 12 | // Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to |
| 13 | 13 | // reuse an existing dist/ bundle while iterating. |
| @@ -16,24 +16,23 @@ import { existsSync } from "node:fs"; · 2 unmodified lines | ||
| 16 | 16 | import { fileURLToPath } from "node:url"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | |
| 19 | −const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); | |
| 20 | 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. | |
| 23 | −const bundlePaths = (): { exe: string; sidecar: string } => { | |
| 21 | +// (launch exe, bundled executor binary) inside the packaged bundle, per platform. | |
| 22 | +const bundlePaths = (): { exe: string; executor: string } => { | |
| 24 | 23 | const arch = process.arch; // arm64 | x64 |
| 25 | 24 | if (process.platform === "darwin") { |
| 26 | 25 | const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app"); |
| 27 | 26 | return { |
| 28 | 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 | 31 | if (process.platform === "win32") { |
| 33 | 32 | const dir = join(appDir, "dist", "win-unpacked"); |
| 34 | 33 | return { |
| 35 | 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 | 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 | 40 | const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`); |
| 42 | 41 | return { |
| 43 | 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 | 48 | process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux"; |
| 50 | 49 | |
| 51 | 50 | export default function setup() { |
| 52 | − const { exe, sidecar } = bundlePaths(); | |
| 51 | + const { exe, executor } = bundlePaths(); | |
| 53 | 52 | |
| 54 | 53 | if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) { |
| 55 | 54 | const run = (cmd: string, args: string[], cwd: string) => |
| 56 | 55 | execFileSync(cmd, args, { cwd, stdio: "inherit", env: { ...process.env } }); |
| 57 | − // 1. web UI bundle (served by the sidecar; staged into the package). | |
| 58 | − run("bun", ["run", "--filter", "@executor-js/local", "build"], repoRoot); | |
| 59 | − // 2. compiled sidecar + native bindings → resources/sidecar. | |
| 56 | + // 1. compiled CLI + embedded web UI + native bindings → resources/executor. | |
| 60 | 57 | run("bun", ["./scripts/build-sidecar.ts"], appDir); |
| 61 | − // 3. electron-vite main/preload → out/. | |
| 58 | + // 2. electron-vite main/preload → out/. | |
| 62 | 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 | 61 | // off so it never reaches for a signing identity. |
| 65 | 62 | execFileSync( |
| 66 | 63 | "bunx", |
| @@ -76,9 +73,9 @@ export default function setup() { · 9 unmodified lines | ||
| 76 | 73 | if (!existsSync(exe)) { |
| 77 | 74 | throw new Error(`packaged desktop exe not found at ${exe} after build`); |
| 78 | 75 | } |
| 79 | − if (!existsSync(sidecar)) { | |
| 80 | − throw new Error(`bundled sidecar not found at ${sidecar} after build`); | |
| 76 | + if (!existsSync(executor)) { | |
| 77 | + throw new Error(`bundled executor not found at ${executor} after build`); | |
| 81 | 78 | } |
| 82 | 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 | 59 | // The PACKAGED desktop app: the real electron-builder bundle, where |
| 60 | 60 | // app.isPackaged is true — the ONLY target that exercises the supervised- |
| 61 | − // daemon attach path (ensureSupervisedConnection) and the bundled compiled | |
| 62 | − // sidecar. Its globalsetup builds the bundle (slow), so it's separate from | |
| 61 | + // daemon attach path (ensureSupervisedConnection) and the bundled executor. | |
| 62 | + // Its globalsetup builds the bundle (slow), so it's separate from | |
| 63 | 63 | // `desktop` to keep the fast dev-electron suite off the package build. |
| 64 | 64 | // Needs a display; not part of the default `npm run test` chain — run with |
| 65 | 65 | // `vitest run --project desktop-packaged`. |
| @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { ServerIcon } from "lucide-react"; |
| 3 | 3 | import { |
| 4 | 4 | getExecutorServerAuthorizationHeader, |
| 5 | + normalizeExecutorServerConnection, | |
| 5 | 6 | useExecutorServerConnection, |
| 6 | 7 | useSetExecutorServerConnection, |
| 7 | 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 | 88 | const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => |
| 78 | 89 | readExecutorServerProfiles(browserStorage()); |
| 79 | 90 | |
| @@ -134,7 +145,7 @@ const serverDescription = (connection: ExecutorServerConnection): string => · 54 unmodified lines | ||
| 134 | 145 | connection.origin.replace(/^https?:\/\//, ""); |
| 135 | 146 | |
| 136 | 147 | const serverKindLabel = (connection: ExecutorServerConnection): string => { |
| 137 | − if (connection.kind === "desktop-sidecar") return "Desktop"; | |
| 148 | + if (connection.kind === "desktop-sidecar") return "Local"; | |
| 138 | 149 | const hostname = new URL(connection.origin).hostname; |
| 139 | 150 | return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" |
| 140 | 151 | ? "Local" |
| @@ -149,6 +160,29 @@ const authLabel = (connection: ExecutorServerConnection): string => { · 8 unmodified lines | ||
| 149 | 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 | 186 | const snapshotWithCurrent = ( |
| 153 | 187 | snapshot: ExecutorServerProfilesSnapshot, |
| 154 | 188 | connection: ExecutorServerConnection, |
| @@ -156,6 +190,14 @@ const snapshotWithCurrent = ( · 1 unmodified line | ||
| 156 | 190 | ): ExecutorServerProfilesSnapshot => |
| 157 | 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 | 201 | const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => { |
| 160 | 202 | const secret = draft.secret.trim(); |
| 161 | 203 | if (draft.authMode === "bearer" && secret) { |
| @@ -201,14 +243,34 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 39 unmodified lines | ||
| 201 | 243 | |
| 202 | 244 | let cancelled = false; |
| 203 | 245 | void readStoredProfiles().then((stored) => { |
| 204 | − if (cancelled) return; | |
| 205 | − const storedActive = getActiveExecutorServerProfile(stored); | |
| 206 | − const next = snapshotWithCurrent(stored, connection, storedActive === null); | |
| 207 | − persistSnapshot(next); | |
| 208 | − if (storedActive && storedActive.key !== connection.key) { | |
| 209 | − setServerConnection(storedActive); | |
| 210 | − } | |
| 211 | − setHydrated(true); | |
| 246 | + void (async () => { | |
| 247 | + if (cancelled) return; | |
| 248 | + const desktopBridge = hasDesktopServerConnectionBridge(); | |
| 249 | + const desktopConnection = | |
| 250 | + (await readDesktopServerConnection()?.then( | |
| 251 | + (value) => value, | |
| 252 | + () => null, | |
| 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 | 276 | return () => { |
| @@ -219,7 +281,10 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 4 unmodified lines | ||
| 219 | 281 | useEffect(() => { |
| 220 | 282 | if (!hydrated) return; |
| 221 | 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 | 288 | writeStoredProfiles(next); |
| 224 | 289 | return next; |
| 225 | 290 | }); |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | ||
| 13 | 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 | 17 | import { defineClientPlugin } from "@executor-js/sdk/client"; |
| 18 | 18 | |
| 19 | 19 | // --------------------------------------------------------------------------- |
| @@ -72,6 +72,12 @@ const inDesktop = readBridge() !== null; · 52 unmodified lines | ||
| 72 | 72 | const describeIpcError = (_err: unknown): string => |
| 73 | 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 | 82 | // SettingsPage |
| 77 | 83 | // --------------------------------------------------------------------------- |
| @@ -168,17 +174,23 @@ function SettingsPage() { · 90 unmodified lines | ||
| 168 | 174 | |
| 169 | 175 | if (!bridge) { |
| 170 | 176 | return ( |
| 171 | − <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}> | |
| 172 | − <h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>Desktop server settings</h1> | |
| 173 | − <p style={{ color: "var(--muted-foreground, #888)" }}> | |
| 174 | − Open this page from Executor Desktop to inspect and change the active server connection. | |
| 175 | − </p> | |
| 177 | + <div style={pageFrameStyle}> | |
| 178 | + <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}> | |
| 179 | + <h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>Desktop server settings</h1> | |
| 180 | + <p style={{ color: "var(--muted-foreground, #888)" }}> | |
| 181 | + Open this page from Executor Desktop to inspect and change the active server connection. | |
| 182 | + </p> | |
| 183 | + </div> | |
| 176 | 184 | </div> |
| 177 | 185 | ); |
| 178 | 186 | } |
| 179 | 187 | |
| 180 | 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 | 196 | const dirty = draft.port !== settings.port; |
| @@ -190,172 +202,102 @@ function SettingsPage() { · 5 unmodified lines | ||
| 190 | 202 | : "executor tools sources --server desktop"; |
| 191 | 203 | |
| 192 | 204 | return ( |
| 193 | − <div style={{ maxWidth: 760, margin: "2rem auto", padding: "1.5rem" }}> | |
| 194 | − <h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.25rem" }}> | |
| 195 | − Desktop server connection | |
| 196 | − </h1> | |
| 197 | − <p | |
| 198 | − style={{ | |
| 199 | − fontSize: "0.875rem", | |
| 200 | − color: "var(--muted-foreground, #888)", | |
| 201 | − marginBottom: "1.5rem", | |
| 202 | − }} | |
| 203 | − > | |
| 204 | − {connection.displayName} | |
| 205 | − </p> | |
| 206 | − | |
| 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> | |
| 205 | + <div style={pageFrameStyle}> | |
| 206 | + <div style={{ maxWidth: 760, margin: "2rem auto", padding: "1.5rem" }}> | |
| 207 | + <h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.25rem" }}> | |
| 208 | + Desktop server connection | |
| 209 | + </h1> | |
| 210 | + <p | |
| 211 | + style={{ | |
| 212 | + fontSize: "0.875rem", | |
| 213 | + color: "var(--muted-foreground, #888)", | |
| 214 | + marginBottom: "1.5rem", | |
| 215 | + }} | |
| 216 | + > | |
| 217 | + {connection.displayName} | |
| 218 | + </p> | |
| 220 | 219 | |
| 221 | − <section | |
| 222 | − style={{ | |
| 223 | − display: "flex", | |
| 224 | − flexDirection: "column", | |
| 225 | − gap: "0.5rem", | |
| 226 | − marginBottom: "1.5rem", | |
| 227 | − padding: "0.85rem", | |
| 228 | − borderRadius: 6, | |
| 229 | − border: "1px solid var(--border, #ddd)", | |
| 230 | − background: "var(--muted, #f5f5f5)", | |
| 231 | − }} | |
| 232 | − > | |
| 233 | − <div style={{ fontSize: "0.8rem", fontWeight: 600 }}>CLI profile</div> | |
| 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> | |
| 220 | + <section | |
| 221 | + style={{ | |
| 222 | + display: "grid", | |
| 223 | + gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))", | |
| 224 | + gap: "0.75rem", | |
| 225 | + marginBottom: "1.5rem", | |
| 226 | + }} | |
| 227 | + > | |
| 228 | + <ConnectionField label="Origin" value={connection.origin} /> | |
| 229 | + <ConnectionField label="API" value={connection.apiBaseUrl} /> | |
| 230 | + <ConnectionField label="Auth" value={authLabel} /> | |
| 231 | + <ConnectionField label="Kind" value={connection.kind} /> | |
| 232 | + </section> | |
| 241 | 233 | |
| 242 | − <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}> | |
| 243 | − <label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | |
| 244 | − <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Port</span> | |
| 245 | − {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} | |
| 246 | − <input | |
| 247 | − type="number" | |
| 248 | − min={1} | |
| 249 | − max={65535} | |
| 250 | − value={draft.port} | |
| 251 | − onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) })} | |
| 252 | − style={{ | |
| 253 | − padding: "0.5rem 0.7rem", | |
| 254 | − borderRadius: 6, | |
| 255 | − border: "1px solid var(--border, #ddd)", | |
| 256 | − fontFamily: "inherit", | |
| 257 | − fontSize: "0.95rem", | |
| 258 | − width: "8rem", | |
| 259 | − }} | |
| 260 | − /> | |
| 261 | − <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | |
| 262 | − Changes restart the connection at <code>http://127.0.0.1:{draft.port}</code>. | |
| 263 | − </span> | |
| 264 | − </label> | |
| 234 | + <section | |
| 235 | + style={{ | |
| 236 | + display: "flex", | |
| 237 | + flexDirection: "column", | |
| 238 | + gap: "0.5rem", | |
| 239 | + marginBottom: "1.5rem", | |
| 240 | + padding: "0.85rem", | |
| 241 | + borderRadius: 6, | |
| 242 | + border: "1px solid var(--border, #ddd)", | |
| 243 | + background: "var(--muted, #f5f5f5)", | |
| 244 | + }} | |
| 245 | + > | |
| 246 | + <div style={{ fontSize: "0.8rem", fontWeight: 600 }}>CLI profile</div> | |
| 247 | + <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> | |
| 248 | + {cliProfileCommand} | |
| 249 | + </code> | |
| 250 | + <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}> | |
| 251 | + {cliUseCommand} | |
| 252 | + </code> | |
| 253 | + </section> | |
| 265 | 254 | |
| 266 | − <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | |
| 267 | − <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Bearer token</span> | |
| 268 | − <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> | |
| 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> | |
| 255 | + <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}> | |
| 256 | + <label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}> | |
| 257 | + <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Port</span> | |
| 283 | 258 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} |
| 284 | − <button | |
| 285 | − type="button" | |
| 286 | − onClick={() => void rotate()} | |
| 287 | − disabled={status !== "idle"} | |
| 259 | + <input | |
| 260 | + type="number" | |
| 261 | + min={1} | |
| 262 | + max={65535} | |
| 263 | + value={draft.port} | |
| 264 | + onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) })} | |
| 288 | 265 | style={{ |
| 289 | − padding: "0.45rem 0.85rem", | |
| 266 | + padding: "0.5rem 0.7rem", | |
| 290 | 267 | borderRadius: 6, |
| 291 | 268 | border: "1px solid var(--border, #ddd)", |
| 292 | − background: "var(--background, white)", | |
| 293 | 269 | fontFamily: "inherit", |
| 294 | − fontSize: "0.85rem", | |
| 295 | − cursor: status === "idle" ? "pointer" : "default", | |
| 270 | + fontSize: "0.95rem", | |
| 271 | + width: "8rem", | |
| 296 | 272 | }} |
| 297 | − > | |
| 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> | |
| 273 | + /> | |
| 348 | 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 | |
| 350 | − folder — attach it when reporting a bug. Your sources, secrets, and bearer token are | |
| 351 | − not included. | |
| 275 | + Changes restart the connection at <code>http://127.0.0.1:{draft.port}</code>. | |
| 352 | 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 | 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 | 296 | {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} |
| 355 | 297 | <button |
| 356 | 298 | type="button" |
| 357 | − onClick={() => void exportDiagnostics()} | |
| 358 | − disabled={diagnostics.state === "exporting"} | |
| 299 | + onClick={() => void rotate()} | |
| 300 | + disabled={status !== "idle"} | |
| 359 | 301 | style={{ |
| 360 | 302 | padding: "0.45rem 0.85rem", |
| 361 | 303 | borderRadius: 6, |
| @@ -363,20 +305,92 @@ function SettingsPage() { · 1 unmodified line | ||
| 363 | 305 | background: "var(--background, white)", |
| 364 | 306 | fontFamily: "inherit", |
| 365 | 307 | fontSize: "0.85rem", |
| 366 | − cursor: diagnostics.state === "exporting" ? "default" : "pointer", | |
| 367 | − width: "fit-content", | |
| 308 | + cursor: status === "idle" ? "pointer" : "default", | |
| 368 | 309 | }} |
| 369 | 310 | > |
| 370 | − {diagnostics.state === "exporting" ? "Exporting…" : "Export diagnostics"} | |
| 311 | + Rotate | |
| 371 | 312 | </button> |
| 372 | − {diagnostics.state === "done" && ( | |
| 373 | − <code style={{ fontSize: "0.75rem", overflow: "auto", whiteSpace: "nowrap" }}> | |
| 374 | − {diagnostics.path} | |
| 375 | − </code> | |
| 376 | − )} | |
| 377 | 313 | </div> |
| 378 | − </section> | |
| 379 | − )} | |
| 314 | + <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}> | |
| 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 | 394 | </div> |
| 381 | 395 | </div> |
| 382 | 396 | ); |
| @@ -22,6 +22,7 @@ import * as React from "react"; · 21 unmodified lines | ||
| 22 | 22 | import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection"; |
| 23 | 23 | |
| 24 | 24 | const STORAGE_KEY = "executor.authToken"; |
| 25 | +const DESKTOP_LAUNCH_CACHE_BUST_PARAM = "_executor_desktop_launch"; | |
| 25 | 26 | |
| 26 | 27 | const isDesktopBridge = (): boolean => |
| 27 | 28 | typeof globalThis.window?.executor?.getServerConnection === "function"; |
| @@ -57,10 +58,23 @@ const applyBearer = (token: string): void => { · 29 unmodified lines | ||
| 57 | 58 | * localStorage. Identical in dev and prod. |
| 58 | 59 | */ |
| 59 | 60 | export const bootstrapLocalAuthToken = (): void => { |
| 60 | − if (isDesktopBridge()) return; | |
| 61 | − | |
| 62 | 61 | const url = globalThis.window ? new URL(window.location.href) : null; |
| 63 | 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 | 78 | if (fromUrl) { |
| 65 | 79 | persistToken(fromUrl); |
| 66 | 80 | url!.searchParams.delete("_token"); |
| @@ -69,6 +83,10 @@ export const bootstrapLocalAuthToken = (): void => { · 2 unmodified lines | ||
| 69 | 83 | return; |
| 70 | 84 | } |
| 71 | 85 | |
| 86 | + if (stripCacheBust) { | |
| 87 | + globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); | |
| 88 | + } | |
| 89 | + | |
| 72 | 90 | const stored = readStoredToken(); |
| 73 | 91 | if (stored) applyBearer(stored); |
| 74 | 92 | }; |
| @@ -127,6 +127,9 @@ interface ExecutorServerConnectionContextValue { · 126 unmodified lines | ||
| 127 | 127 | const ExecutorServerConnectionContext = |
| 128 | 128 | React.createContext<ExecutorServerConnectionContextValue | null>(null); |
| 129 | 129 | |
| 130 | +const hasDesktopServerConnectionBridge = (): boolean => | |
| 131 | + typeof globalThis.window?.executor?.getServerConnection === "function"; | |
| 132 | + | |
| 130 | 133 | export function ExecutorServerConnectionProvider( |
| 131 | 134 | props: React.PropsWithChildren<{ |
| 132 | 135 | readonly connection?: ExecutorServerConnectionInput; |
| @@ -142,6 +145,7 @@ export function ExecutorServerConnectionProvider( · 9 unmodified lines | ||
| 142 | 145 | const [connection, setConnection] = React.useState(initialConnection); |
| 143 | 146 | const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => { |
| 144 | 147 | const next = normalizeExecutorServerConnection(input); |
| 148 | + if (hasDesktopServerConnectionBridge() && next.kind !== "desktop-sidecar") return; | |
| 145 | 149 | activeConnection = next; |
| 146 | 150 | setConnection(next); |
| 147 | 151 | }, []); |
| @@ -160,13 +164,13 @@ export function ExecutorServerConnectionProvider( · 12 unmodified lines | ||
| 160 | 164 | if (typeof bridge?.getServerConnection !== "function") return; |
| 161 | 165 | |
| 162 | 166 | let cancelled = false; |
| 163 | − const initialKey = activeConnection.key; | |
| 164 | 167 | void bridge.getServerConnection().then( |
| 165 | 168 | (input) => { |
| 166 | 169 | if (cancelled || !input) return; |
| 167 | 170 | const next = normalizeExecutorServerConnection(input); |
| 168 | − setConnection((current) => { | |
| 169 | − if (current.key !== initialKey) return current; | |
| 171 | + setConnection(() => { | |
| 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 | 174 | activeConnection = next; |
| 171 | 175 | return next; |
| 172 | 176 | }); |