History

Fix local service convergence

Rhys Sullivan committed 4 months ago

Commit 76b8a331 parent 1547bab33 files · +2172 −998Browse files

Unified · Split

33 files
  1. .github/workflows/ci.yml +1 −1
  2. .github/workflows/publish-desktop.yml +6 −6
  3. apps/cli/package.json +1 −0
  4. apps/cli/src/daemon.test.ts +131 −0
  5. apps/cli/src/daemon.ts +49 −0
  6. apps/cli/src/main.ts +123 −29
  7. apps/cli/src/service.test.ts +17 −4
  8. apps/cli/src/service.ts +37 −16
  9. apps/desktop/build/entitlements.mac.plist +2 −2
  10. apps/desktop/electron-builder.config.ts +7 −12
  11. apps/desktop/electron-builder.e2e.config.ts +1 −1
  12. apps/desktop/package.json +1 −1
  13. apps/desktop/scripts/build-sidecar.ts +38 −244
  14. apps/desktop/scripts/smoke-sidecar.ts +25 −16
  15. apps/desktop/src/main/index.ts +84 −20
  16. apps/desktop/src/main/local-auth.ts +48 −0
  17. apps/desktop/src/main/service.ts +92 −185
  18. apps/desktop/src/main/settings.ts +4 −1
  19. apps/desktop/src/main/sidecar.ts +103 −60
  20. apps/desktop/src/shared/server-settings.ts +1 −1
  21. apps/desktop/src/sidecar/native-bindings.ts +4 −3
  22. apps/desktop/src/sidecar/server.ts +6 −8
  23. apps/local/src/serve.ts +17 −8
  24. bun.lock +1 −0
  25. e2e/cli/service-install-takeover.test.ts +109 −0
  26. e2e/desktop-packaged/supervised-attach.test.ts +273 −72
  27. e2e/desktop-packaged/supervised-regressions.test.ts +690 −105
  28. e2e/setup/desktop-packaged.globalsetup.ts +15 −18
  29. e2e/vitest.config.ts +2 −2
  30. packages/app/src/web/server-connection-menu.tsx +75 −10
  31. packages/plugins/desktop-settings/src/client.tsx +182 −168
  32. packages/react/src/api/local-auth.tsx +20 −2
  33. packages/react/src/api/server-connection.tsx +7 −3
.github/workflows/ci.yml+1−1
@@ -89,7 +89,7 @@ jobs: · 88 unmodified lines
8989 - name: Build web app
9090 run: bun run --filter @executor-js/local build
9191
92− - name: Build sidecar + stage web UI
92+ - name: Build bundled executor
9393 env:
9494 BUN_TARGET: bun-linux-x64
9595 run: bun ./scripts/build-sidecar.ts
.github/workflows/publish-desktop.yml+6−6
@@ -2,8 +2,8 @@ name: Publish Desktop App · 1 unmodified line
22 run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}"
33
44 # 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
77 # release matching the tag so electron-updater can pick them up.
88
99 on:
@@ -97,17 +97,17 @@ jobs: · 87 unmodified lines
9797 - name: Build web app
9898 run: bun run --filter @executor-js/local build
9999
100− - name: Build sidecar binary + stage web UI
100+ - name: Build bundled executor
101101 env:
102102 BUN_TARGET: ${{ matrix.bun-target }}
103103 run: bun ./scripts/build-sidecar.ts
104104 working-directory: apps/desktop
105105
106106 # 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
109109 # node_modules that `bun build --compile` does not bundle.
110− - name: Smoke test compiled sidecar
110+ - name: Smoke test bundled executor
111111 if: matrix.smoke
112112 run: bun run test:smoke
113113 working-directory: apps/desktop
apps/cli/package.json+1−0
@@ -30,6 +30,7 @@ · 29 unmodified lines
3030 "@executor-js/runtime-quickjs": "workspace:*",
3131 "@executor-js/sdk": "workspace:*",
3232 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33+ "@sentry/bun": "^10.57.0",
3334 "effect": "catalog:",
3435 "quickjs-emscripten": "catalog:"
3536 },
apps/cli/src/daemon.test.ts+131−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 canAutoStartLocalDaemonForHost,
88 isDevCliEntrypoint,
99 isExecutorServerReachable,
10+ planServiceInstall,
1011 } from "./daemon";
1112
1213 describe("isDevCliEntrypoint", () => {
@@ -93,3 +94,133 @@ describe("isExecutorServerReachable", () => { · 80 unmodified lines
9394 }),
9495 );
9596 });
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+});
apps/cli/src/daemon.ts+49−0
@@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: { · 281 unmodified lines
282282 }
283283 return fallbackPort;
284284 });
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+};
apps/cli/src/main.ts+123−29
@@ -36,6 +36,25 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 35 unmodified lines
3636 setQuickJSModule(mod);
3737 }
3838
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+
3958 import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
4059 import { BunRuntime, BunServices } from "@effect/platform-bun";
4160 import { HttpApiClient } from "effect/unstable/httpapi";
@@ -71,6 +90,7 @@ import { · 29 unmodified lines
7190 isExecutorServerReachable,
7291 isDevCliEntrypoint,
7392 parseDaemonBaseUrl,
93+ planServiceInstall,
7494 spawnDetached,
7595 waitForReachable,
7696 waitForUnreachable,
@@ -100,7 +120,6 @@ import { · 23 unmodified lines
100120 acquireLocalServerStartLock,
101121 readLocalServerManifest,
102122 releaseLocalServerStartLock,
103− removeLocalServerManifest,
104123 removeLocalServerManifestIfOwnedBy,
105124 resolveExecutorDataDir,
106125 writeLocalServerManifest,
@@ -260,7 +279,7 @@ const makeLocalServerManifest = (input: { · 153 unmodified lines
260279 scopeDir: currentScopeDirForManifest(),
261280 connection: input.connection,
262281 owner: {
263− client: "cli",
282+ client: process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli",
264283 version: CLI_VERSION,
265284 executablePath: isDevMode ? (script ?? null) : process.execPath,
266285 },
@@ -286,6 +305,41 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< · 19 unmodified lines
286305 );
287306 });
288307
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+
289343 const publishLocalServerManifest = (input: {
290344 readonly kind: ExecutorLocalServerKind;
291345 readonly connection: ExecutorServerConnection;
@@ -897,7 +951,7 @@ const runDaemonSession = (input: { · 605 unmodified lines
897951 // and crash-loop under KeepAlive. (Found by a real reboot test with
898952 // integration data in the DB.)
899953 if (process.env.EXECUTOR_SUPERVISED) {
900− yield* removeLocalServerManifest().pipe(Effect.ignore);
954+ yield* takeOverActiveLocalServer().pipe(Effect.ignore);
901955 } else {
902956 yield* assertNoOtherActiveLocalServer();
903957 }
@@ -907,15 +961,34 @@ const runDaemonSession = (input: { · 3 unmodified lines
907961 if (existing) {
908962 const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
909963 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+ }
919992 }
920993 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
921994 }
@@ -2125,6 +2198,17 @@ const mcpCommand = Command.make( · 1203 unmodified lines
21252198
21262199 const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
21272200
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+
21282212 const servicePortOption = () =>
21292213 Options.integer("port")
21302214 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
@@ -2164,26 +2248,36 @@ const installService = (port: number, commandName: string) => · 33 unmodified lines
21642248 return;
21652249 }
21662250
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) {
21732277 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})...`,
21752279 );
2176− console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2177− return;
21782280 }
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− );
21872281 }
21882282
21892283 const path = yield* PlatformPath.Path;
apps/cli/src/service.test.ts+17−4
@@ -13,12 +13,14 @@ describe("service unit generation", () => { · 12 unmodified lines
1313 const launchdInput = {
1414 label: "sh.executor.daemon",
1515 programArguments: [
16− "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar",
16+ "/Applications/Executor.app/Contents/Resources/executor/executor",
1717 "daemon",
1818 "run",
1919 "--foreground",
2020 "--port",
2121 "4789",
22+ "--hostname",
23+ "127.0.0.1",
2224 ],
2325 environment: {
2426 EXECUTOR_SUPERVISED: "1",
@@ -65,13 +67,24 @@ describe("service unit generation", () => { · 40 unmodified lines
6567
6668 it("renders a systemd --user unit with crash-only restart", () => {
6769 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+ ],
6980 environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" },
7081 workingDirectory: "/home/x/.executor",
7182 stdoutPath: "/home/x/.executor/logs/daemon.log",
7283 stderrPath: "/home/x/.executor/logs/daemon.error.log",
7384 });
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+ );
7588 expect(unit).toContain("Restart=on-failure");
7689 expect(unit).toContain("WantedBy=default.target");
7790 expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1");
@@ -92,7 +105,7 @@ describe("service unit generation", () => { · 14 unmodified lines
92105 expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"');
93106 expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"');
94107 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',
96109 );
97110 expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"');
98111 // No secret in the wrapper — the daemon reads the bearer from auth.json at boot.
apps/cli/src/service.ts+37−16
@@ -138,27 +138,48 @@ const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray<s · 137 unmodified lines
138138 "--foreground",
139139 "--port",
140140 String(descriptor.port),
141+ "--hostname",
142+ "127.0.0.1",
141143 ];
142144
143145 const serviceEnvironment = (
144146 descriptor: ServiceDescriptor,
145147 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+};
162183
163184 // ---------------------------------------------------------------------------
164185 // macOS — launchd LaunchAgent (fully built)
apps/desktop/build/entitlements.mac.plist+2−2
@@ -7,12 +7,12 @@ · 6 unmodified lines
77 <true/>
88 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
99 <true/>
10− <!-- Electron reads NODE_OPTIONS / NODE_PATH at startup; the sidecar
10+ <!-- Electron reads NODE_OPTIONS / NODE_PATH at startup; the bundled executor
1111 reads EXECUTOR_* env vars. Hardened runtime would strip them
1212 without this entitlement. -->
1313 <key>com.apple.security.cs.allow-dyld-environment-variables</key>
1414 <true/>
15− <!-- The Bun-compiled executor-sidecar binary inside Resources/sidecar/
15+ <!-- The Bun-compiled executor binary inside Resources/executor/
1616 loads dylibs Apple's library validator doesn't recognize as part
1717 of the Electron bundle's signing chain. -->
1818 <key>com.apple.security.cs.disable-library-validation</key>
apps/desktop/electron-builder.config.ts+7−12
@@ -7,20 +7,15 @@ const config: Configuration = { · 6 unmodified lines
77 directories: {
88 output: "dist",
99 // 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.
1212 buildResources: "build",
1313 },
1414 files: ["out/**/*", "package.json"],
1515 extraResources: [
1616 {
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/",
2419 filter: ["**/*"],
2520 },
2621 ],
@@ -29,7 +24,7 @@ const config: Configuration = { · 2 unmodified lines
2924 // Do NOT pin `arch:` inside the target objects. The publish workflow's
3025 // matrix passes `--arm64` / `--x64` per leg; a config-level arch list
3126 // 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
3328 // -86 / EBADARCH on Apple Silicon). The CLI flag is the source of truth.
3429 target: ["dmg", "zip"],
3530 hardenedRuntime: true,
@@ -45,10 +40,10 @@ const config: Configuration = { · 9 unmodified lines
4540 },
4641 // Same arch rule as mac (see comment above): never pin `arch:` in the
4742 // 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
4944 // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each
5045 // 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.
5247 win: {
5348 target: ["nsis"],
5449 },
apps/desktop/electron-builder.e2e.config.ts+1−1
@@ -1,5 +1,5 @@
11 // 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/
33 // preload `out/`), but skips Apple signing/notarization so it builds with no
44 // CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through
55 // Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is
apps/desktop/package.json+1−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
99 "scripts": {
1010 "predev": "bun run --filter @executor-js/local build",
1111 "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",
1313 "build": "electron-vite build",
1414 "preview": "electron-vite preview",
1515 "package": "electron-builder --config electron-builder.config.ts",
apps/desktop/scripts/build-sidecar.ts+38−244
@@ -1,258 +1,52 @@
11 /**
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.
113 *
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`.
127 */
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";
1810
1911 const ROOT = resolve(import.meta.dir, "..");
2012 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}`;
16129 };
16230
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"];
19833
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}`);
21941 }
22042
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 });
25147
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);
25650 }
25751
258−console.log("[build-sidecar] done");
52+console.log(`[build-sidecar] staged bundled executor → ${EXECUTOR_OUT_DIR}`);
apps/desktop/scripts/smoke-sidecar.ts+25−16
@@ -1,14 +1,14 @@
11 /**
2− * End-to-end smoke test for the compiled sidecar binary.
2+ * End-to-end smoke test for the bundled executor binary.
33 *
44 * 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
66 * .node loaders (keychain), and the MCP → engine → QuickJS → tool path.
77 *
88 * Flow:
99 * 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.
1212 * 3. Connect via MCP streamable HTTP, call the `execute` tool with code
1313 * that registers and invokes the OpenAPI tool, assert the answer
1414 * round-trips as 42.
@@ -29,8 +29,8 @@ const ROOT = resolve(import.meta.dir, ".."); · 14 unmodified lines
2929 const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1");
3030 const BINARY = resolve(
3131 ROOT,
32− "resources/sidecar",
33− process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar",
32+ "resources/executor",
33+ process.platform === "win32" ? "executor.exe" : "executor",
3434 );
3535
3636 const AUTH_TOKEN = "smoke-test-token";
@@ -38,7 +38,7 @@ const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`; · 1 unmodified line
3838 const READY_TIMEOUT_MS = 30_000;
3939
4040 // 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.
4242 const fail = (msg: string): never => {
4343 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error
4444 throw new Error(`[smoke-sidecar] FAIL: ${msg}`);
@@ -246,7 +246,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 201 unmodified lines
246246 new Promise((resolveReady, rejectReady) => {
247247 const deadline = setTimeout(() => {
248248 // 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`));
250250 }, READY_TIMEOUT_MS);
251251
252252 let stdoutBuf = "";
@@ -258,7 +258,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 5 unmodified lines
258258 while (true) {
259259 const { value, done } = await stderrReader.read();
260260 if (done) return;
261− process.stderr.write(`[sidecar-stderr] ${decoder.decode(value)}`);
261+ process.stderr.write(`[executor-stderr] ${decoder.decode(value)}`);
262262 }
263263 })();
264264
@@ -268,13 +268,13 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<n · 3 unmodified lines
268268 if (done) {
269269 clearTimeout(deadline);
270270 // 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"));
272272 return;
273273 }
274274 const chunk = decoder.decode(value);
275− process.stdout.write(`[sidecar-stdout] ${chunk}`);
275+ process.stdout.write(`[executor-stdout] ${chunk}`);
276276 stdoutBuf += chunk;
277− const match = /EXECUTOR_READY:(\d+)/.exec(stdoutBuf);
277+ const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuf);
278278 if (match) {
279279 clearTimeout(deadline);
280280 resolveReady(parseInt(match[1]!, 10));
@@ -344,14 +344,23 @@ const main = async () => { · 63 unmodified lines
344344 console.log(`[smoke-sidecar] openapi: ${openapi.origin}`);
345345
346346 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+ ],
348359 env: {
349360 ...process.env,
350− EXECUTOR_PORT: "0",
351− EXECUTOR_HOST: "127.0.0.1",
352− EXECUTOR_AUTH_TOKEN: AUTH_TOKEN,
353361 EXECUTOR_SCOPE_DIR: scopeDir,
354362 EXECUTOR_DATA_DIR: dataDir,
363+ EXECUTOR_CLIENT: "desktop",
355364 XDG_DATA_HOME: xdgDir,
356365 },
357366 stdin: "ignore",
apps/desktop/src/main/index.ts+84−20
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 } from "./diagnostics";
3737 import { sidecarCrashHtml } from "./crash-screen";
3838 import {
39+ bundledExecutorPath,
3940 installSupervisedService,
4041 restartSupervisedService,
4142 supervisedServiceStatus,
@@ -117,18 +118,66 @@ const stopConnection = async (conn: SidecarConnection): Promise<void> => { · 75 unmodified lines
117118 await stopSidecar(conn.child);
118119 };
119120
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+
120128 // The supervised daemon (and the desktop sidecar) own this data dir — the same
121129 // path the CLI's `executor web`/daemon uses, so desktop and CLI share state.
122130 const DESKTOP_DATA_DIR = join(homedir(), ".executor");
123131
124132 const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
125133
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+
126172 /** 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> => {
128177 const deadline = Date.now() + timeoutMs;
129178 for (;;) {
130179 const attached = await attachToSupervisedDaemon();
131− if (attached) return attached;
180+ if (attached && (options.port === undefined || attached.port === options.port)) return attached;
132181 if (Date.now() >= deadline) return null;
133182 await delay(300);
134183 }
@@ -140,7 +189,7 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 5 unmodified lines
140189 title: "Keep Executor running in the background?",
141190 message: "Keep your connections available after you quit Executor?",
142191 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.",
144193 buttons: ["Keep running in the background", "Not now"],
145194 defaultId: 0,
146195 cancelId: 1,
@@ -156,7 +205,21 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 9 unmodified lines
156205 const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> => {
157206 // 1. Already running → attach.
158207 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+ }
160223
161224 const status = await supervisedServiceStatus();
162225 if (!status.supported) return null;
@@ -189,9 +252,9 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 26 unmodified lines
189252 return waitForSupervisedAttach(15_000);
190253 };
191254
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.
195258 let supervisedMonitorTimer: ReturnType<typeof setInterval> | null = null;
196259 let supervisedDaemonDown = false;
197260
@@ -219,7 +282,7 @@ const armSupervisedMonitor = () => { · 21 unmodified lines
219282 supervisedDaemonDown = false;
220283 connection = live;
221284 installBearerAuthHeader(live.baseUrl, live.authToken);
222− if (window) void window.loadURL(live.baseUrl);
285+ if (window) void window.loadURL(webUrlForConnection(live));
223286 }
224287 })();
225288 }, 10_000);
@@ -380,7 +443,7 @@ const createWindow = async (conn: SidecarConnection) => { · 154 unmodified lines
380443 return { action: "deny" };
381444 });
382445
383− await window.loadURL(conn.baseUrl);
446+ await window.loadURL(webUrlForConnection(conn));
384447 };
385448
386449 const showPortInUseDialog = async (port: number) => {
@@ -418,11 +481,12 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 31 unmodified lines
418481 // A supervised daemon owns its own process lifetime. Re-installing the unit
419482 // rewrites settings such as the configured port, then launchd restarts it.
420483 if (connection?.supervisedDaemon) {
484+ const port = getServerSettings().port;
421485 await installSupervisedService({
422− port: getServerSettings().port,
486+ port,
423487 dataDir: DESKTOP_DATA_DIR,
424488 });
425− const next = await waitForSupervisedAttach(30_000);
489+ const next = await waitForSupervisedAttach(30_000, { port });
426490 if (!next) {
427491 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call
428492 throw new Error("Supervised daemon failed to restart — see Settings");
@@ -430,7 +494,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 1 unmodified line
430494 connection = next;
431495 installBearerAuthHeader(next.baseUrl, next.authToken);
432496 const window = liveMainWindow();
433− if (window) await window.loadURL(next.baseUrl);
497+ if (window) await window.loadURL(webUrlForConnection(next));
434498 return toDesktopServerConnection(next);
435499 }
436500 if (connection) {
@@ -445,7 +509,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 8 unmodified lines
445509 connection = next;
446510 installBearerAuthHeader(next.baseUrl, next.authToken);
447511 const window = liveMainWindow();
448− if (window) await window.loadURL(next.baseUrl);
512+ if (window) await window.loadURL(webUrlForConnection(next));
449513 return toDesktopServerConnection(next);
450514 };
451515
@@ -457,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec · 5 unmodified lines
457521 key: "desktop-sidecar",
458522 origin: conn.baseUrl,
459523 apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,
460− displayName: "Desktop sidecar",
524+ displayName: "Local Executor",
461525 });
462526
463527 const registerIpcHandlers = () => {
@@ -486,14 +550,14 @@ const registerIpcHandlers = () => { · 22 unmodified lines
486550 connection = active;
487551 installBearerAuthHeader(active.baseUrl, active.authToken);
488552 const window = liveMainWindow();
489− if (window) await window.loadURL(active.baseUrl);
553+ if (window) await window.loadURL(webUrlForConnection(active));
490554 return toDesktopServerConnection(active);
491555 }
492556 return restartSidecarAndReload();
493557 });
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.
497561 ipcMain.handle("executor:service:status", () => supervisedServiceStatus());
498562 ipcMain.handle(
499563 "executor:service:set-enabled",
@@ -512,7 +576,7 @@ const registerIpcHandlers = () => { · 12 unmodified lines
512576 armSupervisedMonitor();
513577 installBearerAuthHeader(next.baseUrl, next.authToken);
514578 const window = liveMainWindow();
515− if (window) await window.loadURL(next.baseUrl);
579+ if (window) await window.loadURL(webUrlForConnection(next));
516580 }
517581 return true;
518582 }
@@ -773,7 +837,7 @@ const boot = async () => { · 254 unmodified lines
773837 // Prefer an OS-supervised daemon: attach to one that's running, kick one
774838 // that's installed, or offer to install on first run. Quitting the app then
775839 // 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;
777841 // dev and unsupported platforms keep managed-spawn.
778842 if (app.isPackaged) {
779843 const supervised = await ensureSupervisedConnection();
apps/desktop/src/main/local-auth.ts+48−0
@@ -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());
apps/desktop/src/main/service.ts+92−185
@@ -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 */
162 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";
194 import { join } from "node:path";
205 import { promisify } from "node:util";
216 import { app } from "electron";
227 import log from "electron-log/main.js";
8+import { sidecarCrashReportingEnv } from "./diagnostics";
239
2410 const serviceLog = log.scope("service");
2511 const execFileAsync = promisify(execFile);
@@ -32,112 +18,29 @@ interface CommandResult { · 6 unmodified lines
3218 readonly stderr: string;
3319 }
3420
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("&", "&amp;")
62− .replaceAll("<", "&lt;")
63− .replaceAll(">", "&gt;")
64− .replaceAll('"', "&quot;")
65− .replaceAll("'", "&apos;");
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+}
7726
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;
8530 }
8631
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());
13040
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− */
13741 const captureUserPath = async (): Promise<string | undefined> => {
13842 const shell = process.env.SHELL;
13943 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
14144 try {
14245 const { stdout } = await execFileAsync(shell, ["-ilc", 'printf "%s" "$PATH"'], {
14346 encoding: "utf8",
@@ -150,87 +53,91 @@ const captureUserPath = async (): Promise<string | undefined> => { · 6 unmodified lines
15053 }
15154 };
15255
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+};
15888
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);
16091
16192 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+ };
166106 };
167107
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− */
178108 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,
204114 });
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.");
219117 }
220− serviceLog.info(`installed supervised service on port ${opts.port}`);
118+ serviceLog.info(`installed supervised service via bundled executor on port ${opts.port}`);
221119 };
222120
223121 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");
231130 };
232131
233−/** Restart the supervised daemon atomically (kill + relaunch via launchd). */
234132 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+ }
236143 };
apps/desktop/src/main/settings.ts+4−1
@@ -1,5 +1,5 @@
11 import Store from "electron-store";
2−import { rotateLocalAuthToken } from "@executor-js/local/auth";
2+import { rotateLocalAuthToken } from "./local-auth";
33 import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings";
44
55 interface PersistedShape {
@@ -9,6 +9,9 @@ interface PersistedShape { · 3 unmodified lines
99
1010 const store = new Store<PersistedShape>({
1111 name: "settings",
12+ ...(process.env.EXECUTOR_DESKTOP_SETTINGS_DIR
13+ ? { cwd: process.env.EXECUTOR_DESKTOP_SETTINGS_DIR }
14+ : {}),
1215 defaults: { server: DEFAULT_SERVER_SETTINGS },
1316 });
1417
apps/desktop/src/main/sidecar.ts+103−60
@@ -2,13 +2,11 @@ · 1 unmodified line
22 * Sidecar lifecycle manager run inside the Electron main process.
33 *
44 * 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.
76 *
87 * 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.
1210 */
1311
1412 import { spawn, type ChildProcess } from "node:child_process";
@@ -23,7 +21,7 @@ import { · 8 unmodified lines
2321 parseExecutorLocalServerManifest,
2422 serializeExecutorLocalServerManifest,
2523 } from "@executor-js/sdk/shared";
26−import { loadOrMintLocalAuthToken } from "@executor-js/local/auth";
24+import { loadOrMintLocalAuthToken } from "./local-auth";
2725 import { getServerSettings } from "./settings";
2826 import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
2927 import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
@@ -79,6 +77,9 @@ export interface SidecarConnection { · 49 unmodified lines
7977 * leave MCP serving.
8078 */
8179 readonly supervisedDaemon: boolean;
80+ readonly ownerVersion: string | null;
81+ readonly ownerClient: "cli" | "desktop";
82+ readonly ownerExecutablePath: string | null;
8283 }
8384
8485 export class SidecarPortInUseError extends Error {
@@ -217,40 +218,48 @@ const writeSidecarManifest = (input: { · 132 unmodified lines
217218 sidecarManifestPathByPid.set(input.childPid, input.dataDir);
218219 };
219220
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 } => {
221226 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+ };
225245 }
226246 // Dev: run the TS source directly via bun on PATH.
227247 const repoRoot = resolve(import.meta.dirname, "..", "..", "..", "..");
228248 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 };
230250 };
231251
232252 const resolveClientDir = (): string => {
233− if (app.isPackaged) {
234− return join(process.resourcesPath, "web-ui");
235− }
236253 const repoRoot = resolve(import.meta.dirname, "..", "..", "..", "..");
237254 return resolve(repoRoot, "apps/local/dist");
238255 };
239256
257+const delay = (ms: number): Promise<void> =>
258+ new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
259+
240260 export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> {
241261 const hostname = options.hostname ?? "127.0.0.1";
242262 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−
254263 // data.db and the optional executor.jsonc plugin manifest live under
255264 // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI
256265 // 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
267276 // process holds it so it can inject the header into the webview; the child
268277 // validates against the same value. Always present — auth is unconditional.
269278 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);
271294 let startupLockReleased = false;
272295 const releaseLock = () => {
273296 if (startupLockReleased) return;
274297 startupLockReleased = true;
275− releaseStartupLock();
298+ releaseStartupLock?.();
276299 };
277300
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+ }
285310 }
286311
287312 let child: ChildProcess;
@@ -300,7 +325,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 12 unmodified lines
300325 // The bearer token the child validates and the main process injects into
301326 // the webview. Always set — auth is unconditional.
302327 EXECUTOR_AUTH_TOKEN: authToken,
303− EXECUTOR_CLIENT_DIR: clientDir,
328+ ...(clientDir ? { EXECUTOR_CLIENT_DIR: clientDir } : {}),
304329 EXECUTOR_SCOPE_DIR: scopeDir,
305330 EXECUTOR_DATA_DIR: dataDir,
306331 EXECUTOR_CLIENT: "desktop",
@@ -334,9 +359,11 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 27 unmodified lines
334359
335360 const onStdout = (chunk: Buffer) => {
336361 const text = chunk.toString("utf8");
337− process.stdout.write(`[executor-sidecar] ${text}`);
362+ process.stdout.write(`[executor-server] ${text}`);
338363 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+)/);
340367 if (match && !resolved) {
341368 if (!child.pid) {
342369 reject(
@@ -348,13 +375,15 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines
348375 resolved = true;
349376 const port = parseInt(match[1], 10);
350377 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+ }
358387 releaseLock();
359388 resolveStart({
360389 baseUrl,
@@ -364,6 +393,9 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 3 unmodified lines
364393 authToken,
365394 child,
366395 supervisedDaemon: false,
396+ ownerVersion: app.getVersion() || null,
397+ ownerClient: "desktop",
398+ ownerExecutablePath: command,
367399 });
368400 }
369401 };
@@ -371,7 +403,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 1 unmodified line
371403 const onStderr = (chunk: Buffer) => {
372404 const text = chunk.toString("utf8");
373405 stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT);
374− process.stderr.write(`[executor-sidecar] ${text}`);
406+ process.stderr.write(`[executor-server] ${text}`);
375407 logStderrLine(text);
376408 };
377409
@@ -410,21 +442,25 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 32 unmodified lines
410442
411443 /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
412444 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);
427462 }
463+ return false;
428464 };
429465
430466 /**
@@ -450,7 +486,11 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 19 unmodified lines
450486 const origin = manifest.connection.origin;
451487 const auth = manifest.connection.auth;
452488 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+ }
454494
455495 const url = new URL(origin);
456496 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
462502 authToken,
463503 child: null,
464504 supervisedDaemon: true,
505+ ownerVersion: manifest.owner.version,
506+ ownerClient: manifest.owner.client,
507+ ownerExecutablePath: manifest.owner.executablePath,
465508 };
466509 }
467510
apps/desktop/src/shared/server-settings.ts+1−1
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 * renderer (Settings UI + Connect-an-agent surface) need to agree on it.
77 *
88 * 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
1010 * the webview transparently, so the renderer never sees the credential and
1111 * there is nothing to toggle or persist here.
1212 */
apps/desktop/src/sidecar/native-bindings.ts+4−3
@@ -1,10 +1,11 @@
11 // ---------------------------------------------------------------------------
2−// Native-binding bootstrap for the `bun build --compile` sidecar binary.
2+// Native-binding bootstrap for manually compiled sidecar binaries.
33 //
44 // `bun --compile` bundles JS into bunfs but does NOT include `.node` native
55 // 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
89 // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts.
910 //
1011 // This MUST be the FIRST import in server.ts. ES modules evaluate every import
apps/desktop/src/sidecar/server.ts+6−8
@@ -1,7 +1,7 @@
11 /**
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.
55 *
66 * Reads connection parameters from env, boots the executor server, then
77 * announces readiness with the resolved port on stdout so the Electron
@@ -12,11 +12,9 @@ · 4 unmodified lines
1212 import "./native-bindings";
1313 import { dirname, join } from "node:path";
1414
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.
2018 const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm");
2119 if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) {
2220 const { setQuickJSModule } = await import("@executor-js/runtime-quickjs");
apps/local/src/serve.ts+17−8
@@ -30,6 +30,11 @@ import { · 29 unmodified lines
3030
3131 type StaticHandler = () => Response | Promise<Response>;
3232
33+const htmlResponse = (file: Bun.BunFile): Response =>
34+ new Response(file, {
35+ headers: { "content-type": "text/html", "cache-control": "no-store" },
36+ });
37+
3338 function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
3439 const routes: Record<string, StaticHandler> = {};
3540 // 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
4247 } else {
4348 const file = Bun.file(fullPath);
4449 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+ });
4855 }
4956 }
5057 } catch {}
@@ -60,9 +67,11 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 9 unmodified lines
6067 for (const [key, bunfsPath] of Object.entries(embedded)) {
6168 const file = Bun.file(bunfsPath);
6269 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+ });
6675 }
6776 return routes;
6877 }
@@ -300,11 +309,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 231 unmodified lines
300309 } else if (opts.embeddedWebUI) {
301310 staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
302311 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);
304313 } else {
305314 staticRoutes = collectStaticRoutes(clientDir);
306315 const indexFile = Bun.file(join(clientDir, "index.html"));
307− serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } });
316+ serveIndex = () => htmlResponse(indexFile);
308317 }
309318
310319 const server = Bun.serve({
bun.lock+1−0
@@ -42,6 +42,7 @@ · 41 unmodified lines
4242 "@executor-js/runtime-quickjs": "workspace:*",
4343 "@executor-js/sdk": "workspace:*",
4444 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
45+ "@sentry/bun": "^10.57.0",
4546 "effect": "catalog:",
4647 "quickjs-emscripten": "catalog:",
4748 },
e2e/cli/service-install-takeover.test.ts+109−0
@@ -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+}
e2e/desktop-packaged/supervised-attach.test.ts+273−72
@@ -4,24 +4,22 @@ · 3 unmodified lines
44 // ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the
55 // attach behavior can ONLY be proven against the packaged artifact.
66 //
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
1010 // pointed at the same HOME and prove it attached: the manifest still names the
1111 // daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a
1212 // fresh pid), and the console — served by the bearer-gated daemon — renders,
1313 // which only happens if the app injected the bearer it read from the manifest.
1414 // 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";
1717 import net from "node:net";
1818 import { tmpdir } from "node:os";
19−import { dirname, join } from "node:path";
20−import { promisify } from "node:util";
19+import { join } from "node:path";
2120
2221 import { expect, it } from "@effect/vitest";
2322 import { Effect } from "effect";
24−import { _electron } from "playwright";
2523
2624 import { scenario } from "../src/scenario";
2725 import { RunDir } from "../src/services";
@@ -50,10 +48,138 @@ const guiAvailable = (): boolean => { · 22 unmodified lines
5048 const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon";
5149
5250 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+}
57183
58184 const freePort = (): Promise<number> =>
59185 new Promise((resolve, reject) => {
@@ -76,16 +202,20 @@ interface DaemonStart { · 16 unmodified lines
76202 readonly stderr: string;
77203 }
78204
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> =>
82208 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+ );
84214 let stderr = "";
85215 const settle = (ready: boolean) => resolve({ child, ready, stderr });
86216 const timer = setTimeout(() => settle(false), 60_000);
87217 child.stdout.on("data", (chunk: Buffer) => {
88− if (chunk.toString().includes("EXECUTOR_READY:")) {
218+ if (/Daemon ready on http:\/\//.test(chunk.toString())) {
89219 clearTimeout(timer);
90220 settle(true);
91221 }
@@ -99,16 +229,117 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 7 unmodified lines
99229 });
100230 });
101231
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)`, () => {});
104335 } else {
105336 scenario(
106337 SCENARIO_NAME,
107338 { timeout: 240_000 },
108339 Effect.gen(function* () {
109− if (!appExe || !sidecarBin) {
340+ if (!appExe || !executorBin) {
110341 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?",
112343 );
113344 }
114345 const runDir = yield* RunDir;
@@ -121,58 +352,50 @@ const run = async (runDir: string) => { · 6 unmodified lines
121352 const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-"));
122353 const dataDir = join(home, ".executor");
123354 const manifestPath = join(dataDir, "server-control", "server.json");
124− const videoTmp = join(runDir, ".video-tmp");
125355 const port = await freePort();
126356
127357 let daemon: ChildProcess | undefined;
128− let app: Awaited<ReturnType<typeof _electron.launch>> | undefined;
358+ let app: PackagedApp | undefined;
129359 let stepIndex = 0;
130360
131361 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+ );
142373 daemon = started.child;
143374 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
144375 await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });
145376
146377 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(
148379 "cli-daemon",
149380 );
150381 const daemonPid = daemonManifest.pid;
151382
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;
162387 const step = async (label: string, body: () => Promise<void>) => {
163388 await body();
164389 stepIndex += 1;
165390 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`));
169392 };
170393
171394 // The console only renders once the app has a live connection AND the bearer
172395 // it injects is accepted by the gated daemon — so reaching it proves both the
173396 // attach and the bearer wiring through the packaged session layer.
174397 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);
176399 });
177400
178401 // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still
@@ -185,30 +408,8 @@ const run = async (runDir: string) => { · 6 unmodified lines
185408 );
186409 });
187410 } 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);
212413 rmSync(home, { recursive: true, force: true });
213414 }
214415 };
e2e/desktop-packaged/supervised-regressions.test.ts+690−105
@@ -1,17 +1,25 @@
11 // 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
33 // path is production-only (`app.isPackaged`).
44 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";
615 import { createServer, type IncomingMessage } from "node:http";
716 import net from "node:net";
8−import { tmpdir } from "node:os";
17+import { homedir, tmpdir } from "node:os";
918 import { dirname, join } from "node:path";
1019 import { promisify } from "node:util";
1120
1221 import { expect, it } from "@effect/vitest";
1322 import { Effect } from "effect";
14−import { _electron, type ElectronApplication } from "playwright";
1523 import {
1624 normalizeExecutorServerConnection,
1725 serializeExecutorLocalServerManifest,
@@ -21,6 +29,9 @@ import { scenario } from "../src/scenario"; · 3 unmodified lines
2129 import { RunDir } from "../src/services";
2230 import { waitForHttp } from "../setup/boot";
2331
32+const execFileAsync = promisify(execFile);
33+const SERVICE_LABEL = "sh.executor.daemon";
34+
2435 interface PackagedExecutorBridge {
2536 readonly getSettings: () => Promise<{ readonly port: number }>;
2637 readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>;
@@ -28,6 +39,173 @@ interface PackagedExecutorBridge { · 1 unmodified line
2839 readonly getServerConnection: () => Promise<{ readonly origin: string } | null>;
2940 }
3041
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+
31209 declare global {
32210 interface Window {
33211 readonly executor: PackagedExecutorBridge;
@@ -35,8 +213,7 @@ declare global { · 1 unmodified line
35213 }
36214
37215 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;
40217
41218 const guiAvailable = (): boolean => {
42219 if (process.platform === "darwin") {
@@ -65,13 +242,102 @@ const packagedSingleInstanceAvailable = (): boolean => { · 22 unmodified lines
65242 }
66243 };
67244
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) {
70247 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?",
72249 );
73250 }
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+ }
75341 };
76342
77343 const freePort = (): Promise<number> =>
@@ -90,10 +356,18 @@ interface DaemonStart { · 12 unmodified lines
90356 readonly stderr: string;
91357 }
92358
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> =>
94364 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+ );
97371 let stderr = "";
98372 let settled = false;
99373 const settle = (ready: boolean) => {
@@ -103,7 +377,7 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 3 unmodified lines
103377 };
104378 const timer = setTimeout(() => settle(false), 60_000);
105379 child.stdout.on("data", (chunk: Buffer) => {
106− if (chunk.toString().includes("EXECUTOR_READY:")) {
380+ if (/Daemon ready on http:\/\//.test(chunk.toString())) {
107381 clearTimeout(timer);
108382 settle(true);
109383 }
@@ -117,34 +391,228 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> => · 7 unmodified lines
117391 });
118392 });
119393
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 });
146615 }
147− rmSync(videoTmp, { recursive: true, force: true });
148616 };
149617
150618 scenario(
@@ -159,16 +627,17 @@ scenario( · 8 unmodified lines
159627 let daemon: ChildProcess | undefined;
160628 const previousUmask = process.umask(0o022);
161629 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+ );
172641 daemon = started.child;
173642 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(
174643 true,
@@ -194,10 +663,7 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 19 unmodified lines
194663 scenario(
195664 "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer",
196665 { timeout: 240_000 },
197− Effect.gen(function* () {
198− const runDir = yield* RunDir;
199− yield* Effect.promise(() => runStaleManifestProbe(runDir));
200− }),
666+ Effect.promise(() => runStaleManifestProbe()),
201667 );
202668
203669 scenario(
@@ -208,24 +674,24 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 4 unmodified lines
208674 yield* Effect.promise(() => runSupervisedPortSetting(runDir));
209675 }),
210676 );
211−}
212677
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+}
222687
223−const runStaleManifestProbe = async (runDir: string) => {
688+const runStaleManifestProbe = async () => {
224689 const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-"));
225690 const dataDir = join(home, ".executor");
226691 const controlDir = join(dataDir, "server-control");
227− const videoTmp = join(runDir, ".video-tmp");
692+ const manifestPath = join(controlDir, "server.json");
228693 const token = "stale-manifest-leaked-token";
694+ const launchdSnapshot = captureLaunchdService();
229695 const requests: Array<{ readonly url: string; readonly authorization: string | null }> = [];
230696 let resolveFirst!: () => void;
231697 const firstRequest = new Promise<void>((resolve) => {
@@ -238,11 +704,12 @@ const runStaleManifestProbe = async (runDir: string) => { · 6 unmodified lines
238704 });
239705 resolveFirst();
240706 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>");
242708 });
243709 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
244710 const port = (server.address() as net.AddressInfo).port;
245− let app: ElectronApplication | undefined;
711+ let appProcess: ChildProcess | undefined;
712+ let appOutput = "";
246713
247714 try {
248715 mkdirSync(controlDir, { recursive: true });
@@ -265,19 +732,35 @@ const runStaleManifestProbe = async (runDir: string) => { · 16 unmodified lines
265732 { mode: 0o600 },
266733 );
267734
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+
269745 const probed = await Promise.race([
270746 firstRequest.then(() => true),
271747 new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)),
272748 ]);
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);
274750
275751 expect(
276752 requests[0]?.authorization ?? null,
277753 "the stale-manifest reachability probe must not disclose the saved bearer",
278754 ).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);
279761 } finally {
280− await closeWithVideo(app, runDir, videoTmp);
762+ await stopProcess(appProcess);
763+ await restoreLaunchdService(launchdSnapshot);
281764 await new Promise<void>((resolve) => server.close(() => resolve()));
282765 rmSync(home, { recursive: true, force: true });
283766 }
@@ -286,64 +769,166 @@ const runStaleManifestProbe = async (runDir: string) => { · 2 unmodified lines
286769 const runSupervisedPortSetting = async (runDir: string) => {
287770 const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-"));
288771 const dataDir = join(home, ".executor");
289− const videoTmp = join(runDir, ".video-tmp");
772+ const launchdSnapshot = captureLaunchdService();
290773 const oldPort = await freePort();
291774 const newPort = await freePort();
292775 let daemon: ChildProcess | undefined;
293− let app: ElectronApplication | undefined;
776+ let app: PackagedApp | undefined;
294777
295778 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+ );
306790 daemon = started.child;
307791 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
308792 await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 });
309793
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"));
313800
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+ );
317804 expect(new URL(before!.origin).port, "test starts attached to the original port").toBe(
318805 String(oldPort),
319806 );
320807
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)} })`);
324809
325810 await page
326− .evaluate(async () => {
327− await window.executor.restartServer();
328− })
811+ .evaluate("window.executor.restartServer().catch(() => undefined)")
329812 .catch(() => undefined);
330− await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
813+ page = await reconnectPackagedPage(app);
814+ await page.waitForText("Settings", 120_000);
331815
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+ );
338822
339823 expect(after.settings.port, "the setting was persisted").toBe(newPort);
340824 expect(
341825 new URL(after.connection!.origin).port,
342826 "after restart, the active supervised daemon should be serving on the saved port",
343827 ).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));
344928 } finally {
345− await closeWithVideo(app, runDir, videoTmp);
346− daemon?.kill("SIGTERM");
929+ await closePackaged(app);
930+ await stopProcess(daemon);
931+ await restoreLaunchdService(launchdSnapshot);
347932 rmSync(home, { recursive: true, force: true });
348933 }
349934 };
e2e/setup/desktop-packaged.globalsetup.ts+15−18
@@ -2,12 +2,12 @@ · 1 unmodified line
22 // dev electron) so the scenarios drive the production artifact — the only place
33 // app.isPackaged is true, which is what gates the supervised-daemon attach path
44 // (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled
5−// compiled sidecar (executor-sidecar + extraResources). The dev-electron desktop
5+// bundled executor CLI binary. The dev-electron desktop
66 // project can't reach any of that.
77 //
8−// Builds web UI → compiled sidecar → electron-vite main/preload → electron-builder
8+// Builds bundled executor → electron-vite main/preload → electron-builder
99 // (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.
1111 //
1212 // Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to
1313 // reuse an existing dist/ bundle while iterating.
@@ -16,24 +16,23 @@ import { existsSync } from "node:fs"; · 2 unmodified lines
1616 import { fileURLToPath } from "node:url";
1717 import { join } from "node:path";
1818
19−const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
2019 const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
2120
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 } => {
2423 const arch = process.arch; // arm64 | x64
2524 if (process.platform === "darwin") {
2625 const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app");
2726 return {
2827 exe: join(app, "Contents/MacOS/Executor"),
29− sidecar: join(app, "Contents/Resources/sidecar/executor-sidecar"),
28+ executor: join(app, "Contents/Resources/executor/executor"),
3029 };
3130 }
3231 if (process.platform === "win32") {
3332 const dir = join(appDir, "dist", "win-unpacked");
3433 return {
3534 exe: join(dir, "Executor.exe"),
36− sidecar: join(dir, "resources/sidecar/executor-sidecar.exe"),
35+ executor: join(dir, "resources/executor/executor.exe"),
3736 };
3837 }
3938 // electron-builder names the dir `linux-unpacked` for x64 and
@@ -41,7 +40,7 @@ const bundlePaths = (): { exe: string; sidecar: string } => { · 1 unmodified line
4140 const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`);
4241 return {
4342 exe: join(dir, "executor-desktop"),
44− sidecar: join(dir, "resources/sidecar/executor-sidecar"),
43+ executor: join(dir, "resources/executor/executor"),
4544 };
4645 };
4746
@@ -49,18 +48,16 @@ const builderFlag = · 1 unmodified line
4948 process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux";
5049
5150 export default function setup() {
52− const { exe, sidecar } = bundlePaths();
51+ const { exe, executor } = bundlePaths();
5352
5453 if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) {
5554 const run = (cmd: string, args: string[], cwd: string) =>
5655 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.
6057 run("bun", ["./scripts/build-sidecar.ts"], appDir);
61− // 3. electron-vite main/preload → out/.
58+ // 2. electron-vite main/preload → out/.
6259 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
6461 // off so it never reaches for a signing identity.
6562 execFileSync(
6663 "bunx",
@@ -76,9 +73,9 @@ export default function setup() { · 9 unmodified lines
7673 if (!existsSync(exe)) {
7774 throw new Error(`packaged desktop exe not found at ${exe} after build`);
7875 }
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`);
8178 }
8279 process.env.E2E_DESKTOP_APP_EXE = exe;
83− process.env.E2E_DESKTOP_SIDECAR_BIN = sidecar;
80+ process.env.E2E_DESKTOP_EXECUTOR_BIN = executor;
8481 }
e2e/vitest.config.ts+2−2
@@ -58,8 +58,8 @@ export default defineConfig({ · 57 unmodified lines
5858 }),
5959 // The PACKAGED desktop app: the real electron-builder bundle, where
6060 // 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
6363 // `desktop` to keep the fast dev-electron suite off the package build.
6464 // Needs a display; not part of the default `npm run test` chain — run with
6565 // `vitest run --project desktop-packaged`.
packages/app/src/web/server-connection-menu.tsx+75−10
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; · 1 unmodified line
22 import { ServerIcon } from "lucide-react";
33 import {
44 getExecutorServerAuthorizationHeader,
5+ normalizeExecutorServerConnection,
56 useExecutorServerConnection,
67 useSetExecutorServerConnection,
78 type ExecutorServerAuth,
@@ -74,6 +75,16 @@ const desktopProfileStorageBridge = (): DesktopProfileStorageBridge | null => { · 66 unmodified lines
7475 };
7576 };
7677
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+
7788 const readBrowserProfiles = (): ExecutorServerProfilesSnapshot =>
7889 readExecutorServerProfiles(browserStorage());
7990
@@ -134,7 +145,7 @@ const serverDescription = (connection: ExecutorServerConnection): string => · 54 unmodified lines
134145 connection.origin.replace(/^https?:\/\//, "");
135146
136147 const serverKindLabel = (connection: ExecutorServerConnection): string => {
137− if (connection.kind === "desktop-sidecar") return "Desktop";
148+ if (connection.kind === "desktop-sidecar") return "Local";
138149 const hostname = new URL(connection.origin).hostname;
139150 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
140151 ? "Local"
@@ -149,6 +160,29 @@ const authLabel = (connection: ExecutorServerConnection): string => { · 8 unmodified lines
149160 return "Auth";
150161 };
151162
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+
152186 const snapshotWithCurrent = (
153187 snapshot: ExecutorServerProfilesSnapshot,
154188 connection: ExecutorServerConnection,
@@ -156,6 +190,14 @@ const snapshotWithCurrent = ( · 1 unmodified line
156190 ): ExecutorServerProfilesSnapshot =>
157191 upsertExecutorServerProfile(snapshot, connection, { makeActive }) ?? snapshot;
158192
193+const withoutLoopbackProfiles = (
194+ snapshot: ExecutorServerProfilesSnapshot,
195+): ExecutorServerProfilesSnapshot =>
196+ normalizeExecutorServerProfilesSnapshot({
197+ activeKey: snapshot.activeKey,
198+ profiles: snapshot.profiles.filter((profile) => !isLoopbackConnection(profile)),
199+ });
200+
159201 const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => {
160202 const secret = draft.secret.trim();
161203 if (draft.authMode === "bearer" && secret) {
@@ -201,14 +243,34 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 39 unmodified lines
201243
202244 let cancelled = false;
203245 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+ })();
212274 });
213275
214276 return () => {
@@ -219,7 +281,10 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { · 4 unmodified lines
219281 useEffect(() => {
220282 if (!hydrated) return;
221283 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);
223288 writeStoredProfiles(next);
224289 return next;
225290 });
packages/plugins/desktop-settings/src/client.tsx+182−168
@@ -13,7 +13,7 @@ · 12 unmodified lines
1313 * module-init time — so the web UI doesn't show a non-functional link.
1414 */
1515
16−import { useCallback, useEffect, useState } from "react";
16+import { type CSSProperties, useCallback, useEffect, useState } from "react";
1717 import { defineClientPlugin } from "@executor-js/sdk/client";
1818
1919 // ---------------------------------------------------------------------------
@@ -72,6 +72,12 @@ const inDesktop = readBridge() !== null; · 52 unmodified lines
7272 const describeIpcError = (_err: unknown): string =>
7373 "Save failed — check the desktop console for details.";
7474
75+const pageFrameStyle: CSSProperties = {
76+ minHeight: 0,
77+ height: "100%",
78+ overflowY: "auto",
79+};
80+
7581 // ---------------------------------------------------------------------------
7682 // SettingsPage
7783 // ---------------------------------------------------------------------------
@@ -168,17 +174,23 @@ function SettingsPage() { · 90 unmodified lines
168174
169175 if (!bridge) {
170176 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>
176184 </div>
177185 );
178186 }
179187
180188 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+ );
182194 }
183195
184196 const dirty = draft.port !== settings.port;
@@ -190,172 +202,102 @@ function SettingsPage() { · 5 unmodified lines
190202 : "executor tools sources --server desktop";
191203
192204 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>
220219
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>
241233
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>
265254
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>
283258 {/* 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) })}
288265 style={{
289− padding: "0.45rem 0.85rem",
266+ padding: "0.5rem 0.7rem",
290267 borderRadius: 6,
291268 border: "1px solid var(--border, #ddd)",
292− background: "var(--background, white)",
293269 fontFamily: "inherit",
294− fontSize: "0.85rem",
295− cursor: status === "idle" ? "pointer" : "default",
270+ fontSize: "0.95rem",
271+ width: "8rem",
296272 }}
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+ />
348274 <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>.
352276 </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>
353281 <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>
354296 {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}
355297 <button
356298 type="button"
357− onClick={() => void exportDiagnostics()}
358− disabled={diagnostics.state === "exporting"}
299+ onClick={() => void rotate()}
300+ disabled={status !== "idle"}
359301 style={{
360302 padding: "0.45rem 0.85rem",
361303 borderRadius: 6,
@@ -363,20 +305,92 @@ function SettingsPage() { · 1 unmodified line
363305 background: "var(--background, white)",
364306 fontFamily: "inherit",
365307 fontSize: "0.85rem",
366− cursor: diagnostics.state === "exporting" ? "default" : "pointer",
367− width: "fit-content",
308+ cursor: status === "idle" ? "pointer" : "default",
368309 }}
369310 >
370− {diagnostics.state === "exporting" ? "Exporting…" : "Export diagnostics"}
311+ Rotate
371312 </button>
372− {diagnostics.state === "done" && (
373− <code style={{ fontSize: "0.75rem", overflow: "auto", whiteSpace: "nowrap" }}>
374− {diagnostics.path}
375− </code>
376− )}
377313 </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>
380394 </div>
381395 </div>
382396 );
packages/react/src/api/local-auth.tsx+20−2
@@ -22,6 +22,7 @@ import * as React from "react"; · 21 unmodified lines
2222 import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection";
2323
2424 const STORAGE_KEY = "executor.authToken";
25+const DESKTOP_LAUNCH_CACHE_BUST_PARAM = "_executor_desktop_launch";
2526
2627 const isDesktopBridge = (): boolean =>
2728 typeof globalThis.window?.executor?.getServerConnection === "function";
@@ -57,10 +58,23 @@ const applyBearer = (token: string): void => { · 29 unmodified lines
5758 * localStorage. Identical in dev and prod.
5859 */
5960 export const bootstrapLocalAuthToken = (): void => {
60− if (isDesktopBridge()) return;
61−
6261 const url = globalThis.window ? new URL(window.location.href) : null;
6362 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+
6478 if (fromUrl) {
6579 persistToken(fromUrl);
6680 url!.searchParams.delete("_token");
@@ -69,6 +83,10 @@ export const bootstrapLocalAuthToken = (): void => { · 2 unmodified lines
6983 return;
7084 }
7185
86+ if (stripCacheBust) {
87+ globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash);
88+ }
89+
7290 const stored = readStoredToken();
7391 if (stored) applyBearer(stored);
7492 };
packages/react/src/api/server-connection.tsx+7−3
@@ -127,6 +127,9 @@ interface ExecutorServerConnectionContextValue { · 126 unmodified lines
127127 const ExecutorServerConnectionContext =
128128 React.createContext<ExecutorServerConnectionContextValue | null>(null);
129129
130+const hasDesktopServerConnectionBridge = (): boolean =>
131+ typeof globalThis.window?.executor?.getServerConnection === "function";
132+
130133 export function ExecutorServerConnectionProvider(
131134 props: React.PropsWithChildren<{
132135 readonly connection?: ExecutorServerConnectionInput;
@@ -142,6 +145,7 @@ export function ExecutorServerConnectionProvider( · 9 unmodified lines
142145 const [connection, setConnection] = React.useState(initialConnection);
143146 const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => {
144147 const next = normalizeExecutorServerConnection(input);
148+ if (hasDesktopServerConnectionBridge() && next.kind !== "desktop-sidecar") return;
145149 activeConnection = next;
146150 setConnection(next);
147151 }, []);
@@ -160,13 +164,13 @@ export function ExecutorServerConnectionProvider( · 12 unmodified lines
160164 if (typeof bridge?.getServerConnection !== "function") return;
161165
162166 let cancelled = false;
163− const initialKey = activeConnection.key;
164167 void bridge.getServerConnection().then(
165168 (input) => {
166169 if (cancelled || !input) return;
167170 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.
170174 activeConnection = next;
171175 return next;
172176 });