History

Fix supervised/local regressions (#1020)

* fix supervised and local regressions * Update CLI web setup flow * Fix launchd reinstall after disable * Improve CLI install progress output * Fix local service convergence * Fix Windows service uninstall cleanup

Rhys Sullivan committed 5 months ago

Commit aa148a21 parent 130d0e945 files · +3419 −1027Browse files

Unified · Split

45 files
  1. .github/workflows/ci.yml +1 −1
  2. .github/workflows/publish-desktop.yml +6 −6
  3. README.md +6 −3
  4. apps/cli/package.json +1 −0
  5. apps/cli/src/daemon.test.ts +131 −0
  6. apps/cli/src/daemon.ts +51 −2
  7. apps/cli/src/main.ts +283 −96
  8. apps/cli/src/service.test.ts +17 −4
  9. apps/cli/src/service.ts +60 −23
  10. apps/desktop/build/entitlements.mac.plist +2 −2
  11. apps/desktop/electron-builder.config.ts +7 −12
  12. apps/desktop/electron-builder.e2e.config.ts +1 −1
  13. apps/desktop/package.json +1 −1
  14. apps/desktop/scripts/build-sidecar.ts +38 −244
  15. apps/desktop/scripts/smoke-sidecar.ts +25 −16
  16. apps/desktop/src/main/index.ts +96 −21
  17. apps/desktop/src/main/local-auth.ts +48 −0
  18. apps/desktop/src/main/service.ts +92 −182
  19. apps/desktop/src/main/settings.ts +4 −1
  20. apps/desktop/src/main/sidecar.ts +105 −64
  21. apps/desktop/src/shared/server-settings.ts +1 −1
  22. apps/desktop/src/sidecar/native-bindings.ts +4 −3
  23. apps/desktop/src/sidecar/server.ts +9 −9
  24. apps/host-selfhost/src/mcp/index.ts +35 −6
  25. apps/local/src/serve.ts +17 −8
  26. apps/local/vite.config.ts +31 −13
  27. bun.lock +1 −0
  28. e2e/cli/service-install-takeover.test.ts +213 −0
  29. e2e/desktop-packaged/supervised-attach.test.ts +273 −72
  30. e2e/desktop-packaged/supervised-regressions.test.ts +934 −0
  31. e2e/local/auth.test.ts +10 −8
  32. e2e/local/cli-open-stale-manifest.test.ts +93 −0
  33. e2e/local/cli-systemd-unit-escaping.test.ts +44 −0
  34. e2e/local/cli-web-transition.test.ts +57 −0
  35. e2e/local/local-server.ts +15 −12
  36. e2e/local/mcp-browser-approve.test.ts +6 −5
  37. e2e/local/vite-dev-routing.test.ts +228 −0
  38. e2e/selfhost/mcp-browser-approval-ownership.test.ts +138 −0
  39. e2e/setup/desktop-packaged.globalsetup.ts +15 −18
  40. e2e/vitest.config.ts +2 −2
  41. packages/app/src/web/server-connection-menu.tsx +75 −10
  42. packages/hosts/mcp/src/in-memory-session-store.ts +34 −8
  43. packages/plugins/desktop-settings/src/client.tsx +182 −168
  44. packages/react/src/api/local-auth.tsx +20 −2
  45. 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
README.md+6−3
@@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e · 9 unmodified lines
1010
1111 ```bash
1212 npm install -g executor
13+executor install
1314 executor web
1415 ```
1516
16−This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools.
17+This installs the local background service and opens the web UI. From there, add your first source and start using tools.
1718
1819 ### Use as an MCP server
1920
@@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h · 40 unmodified lines
6061
6162 ### Via the web UI
6263
63−Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
64+Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
6465
6566 ### Via the CLI
6667
@@ -119,7 +120,9 @@ executor resume --execution-id exec_123 · 52 unmodified lines
119120 ## CLI reference
120121
121122 ```bash
122−executor web # start runtime + web UI
123+executor install # install/start the durable background service
124+executor web # open the running web UI
125+executor web --foreground # start a temporary foreground runtime + web UI
123126 executor daemon run # start persistent local daemon in background
124127 executor daemon status # show daemon status
125128 executor daemon stop # stop daemon
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+51−2
@@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( · 88 unmodified lines
8989 // misconfigured base URL can't leak the bearer token to a third-party host.
9090 const url = new URL("/api/health", input.baseUrl);
9191 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
92− await response.body?.cancel();
93− return response.ok;
92+ const body = await response.text();
93+ return response.ok && body.trim() === "ok";
9494 }).pipe(Effect.catchCause(() => Effect.succeed(false)));
9595
9696 // ---------------------------------------------------------------------------
@@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: { · 185 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+283−96
@@ -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,44 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< · 19 unmodified lines
286305 );
287306 });
288307
308+const takeOverActiveLocalServer = (input?: {
309+ readonly onlyKind?: ExecutorLocalServerKind;
310+}): Effect.Effect<
311+ ExecutorLocalServerManifest | null,
312+ Error,
313+ FileSystem.FileSystem | PlatformPath.Path
314+> =>
315+ Effect.gen(function* () {
316+ const manifest = yield* readLocalServerManifest();
317+ if (!manifest) return null;
318+ if (input?.onlyKind && manifest.kind !== input.onlyKind) return null;
319+
320+ if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) {
321+ yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
322+ return null;
323+ }
324+
325+ yield* terminatePid(manifest.pid).pipe(Effect.ignore);
326+ const stopped = yield* waitForUnreachable({
327+ check: isServerReachable(manifest.connection.origin),
328+ timeoutMs: DAEMON_STOP_TIMEOUT_MS,
329+ intervalMs: DAEMON_BOOT_POLL_MS,
330+ });
331+ if (!stopped) {
332+ return yield* Effect.fail(
333+ new Error(
334+ [
335+ `The existing Executor ${manifest.kind} at ${manifest.connection.origin} (pid ${manifest.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`,
336+ "Stop it manually and re-run.",
337+ ].join("\n"),
338+ ),
339+ );
340+ }
341+
342+ yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
343+ return manifest;
344+ });
345+
289346 const publishLocalServerManifest = (input: {
290347 readonly kind: ExecutorLocalServerKind;
291348 readonly connection: ExecutorServerConnection;
@@ -897,7 +954,7 @@ const runDaemonSession = (input: { · 605 unmodified lines
897954 // and crash-loop under KeepAlive. (Found by a real reboot test with
898955 // integration data in the DB.)
899956 if (process.env.EXECUTOR_SUPERVISED) {
900− yield* removeLocalServerManifest().pipe(Effect.ignore);
957+ yield* takeOverActiveLocalServer().pipe(Effect.ignore);
901958 } else {
902959 yield* assertNoOtherActiveLocalServer();
903960 }
@@ -907,15 +964,34 @@ const runDaemonSession = (input: { · 3 unmodified lines
907964 if (existing) {
908965 const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
909966 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− );
967+ if (process.env.EXECUTOR_SUPERVISED) {
968+ yield* terminatePid(existing.pid).pipe(Effect.ignore);
969+ const stopped = yield* waitForUnreachable({
970+ check: isServerReachable(existingUrl),
971+ timeoutMs: DAEMON_STOP_TIMEOUT_MS,
972+ intervalMs: DAEMON_BOOT_POLL_MS,
973+ });
974+ if (!stopped) {
975+ return yield* Effect.fail(
976+ new Error(
977+ [
978+ `The existing daemon for scope ${scopeId} at ${existingUrl} (pid ${existing.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`,
979+ "Stop it manually and re-run.",
980+ ].join("\n"),
981+ ),
982+ );
983+ }
984+ } else {
985+ return yield* Effect.fail(
986+ new Error(
987+ [
988+ `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
989+ `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
990+ `Stop it first: ${cliPrefix} daemon stop`,
991+ ].join("\n"),
992+ ),
993+ );
994+ }
919995 }
920996 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
921997 }
@@ -1916,28 +1992,45 @@ const serverCommand = Command.make("server").pipe( · 994 unmodified lines
19161992 const webCommand = Command.make(
19171993 "web",
19181994 {
1919− port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)),
1995+ foreground: Options.boolean("foreground")
1996+ .pipe(Options.withDefault(false))
1997+ .pipe(
1998+ Options.withDescription(
1999+ "Run a temporary web server in this terminal. By default, web opens the installed background service.",
2000+ ),
2001+ ),
2002+ port: Options.integer("port")
2003+ .pipe(Options.withDefault(DEFAULT_PORT))
2004+ .pipe(Options.withDescription("Port for the temporary --foreground server.")),
19202005 hostname: Options.string("hostname")
19212006 .pipe(Options.withDefault("127.0.0.1"))
1922− .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")),
2007+ .pipe(
2008+ Options.withDescription(
2009+ "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.",
2010+ ),
2011+ ),
19232012 allowedHost: Options.string("allowed-host")
19242013 .pipe(Options.atLeast(0))
19252014 .pipe(
19262015 Options.withDescription(
1927− "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
2016+ "For --foreground, grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
19282017 ),
19292018 ),
19302019 authToken: Options.string("auth-token")
19312020 .pipe(Options.optional)
19322021 .pipe(
19332022 Options.withDescription(
1934− "Override the bearer token. Defaults to the stable token in auth.json.",
2023+ "For --foreground, override the bearer token. Defaults to the stable token in auth.json.",
19352024 ),
19362025 ),
19372026 scope,
19382027 },
1939− ({ port, scope, hostname, allowedHost, authToken }) =>
2028+ ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
19402029 Effect.gen(function* () {
2030+ if (!foreground) {
2031+ yield* openRunningLocalWebApp();
2032+ return;
2033+ }
19412034 applyScope(scope);
19422035 yield* runForegroundSession({
19432036 port,
@@ -1946,7 +2039,7 @@ const webCommand = Command.make( · 2 unmodified lines
19462039 authToken: Option.getOrUndefined(authToken),
19472040 });
19482041 }),
1949−).pipe(Command.withDescription("Start a foreground web session"));
2042+).pipe(Command.withDescription("Open the Executor web UI"));
19502043
19512044 const daemonRunCommand = Command.make(
19522045 "run",
@@ -2108,80 +2201,132 @@ const mcpCommand = Command.make( · 155 unmodified lines
21082201
21092202 const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
21102203
2111−const serviceInstallCommand = Command.make(
2112− "install",
2113− {
2114− port: Options.integer("port")
2115− .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2116− .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
2117− },
2118− ({ port }) =>
2119− Effect.gen(function* () {
2120− if (isDevMode) {
2121− return yield* Effect.fail(
2122− new Error(
2123− [
2124− "`service install` requires the compiled `executor` binary so the OS can run it directly.",
2125− `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2126− ].join("\n"),
2127− ),
2128− );
2129− }
2204+const portFromOrigin = (origin: string): number | null => {
2205+ try {
2206+ const url = new URL(origin);
2207+ if (!url.port) return url.protocol === "https:" ? 443 : 80;
2208+ const port = Number.parseInt(url.port, 10);
2209+ return Number.isInteger(port) && port > 0 ? port : null;
2210+ } catch {
2211+ return null;
2212+ }
2213+};
21302214
2131− const backend = getServiceBackend();
2132− if (!backend.automated) {
2133− // Unsupported platforms surface their manual steps via the install error.
2134− yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2135− return;
2136− }
2215+const servicePortOption = () =>
2216+ Options.integer("port")
2217+ .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2218+ .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
2219+
2220+const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["platform"]): string => {
2221+ switch (platform) {
2222+ case "darwin":
2223+ return "launchd";
2224+ case "linux":
2225+ return "systemd --user";
2226+ case "win32":
2227+ return "Windows Task Scheduler";
2228+ case "unsupported":
2229+ return "manual setup";
2230+ }
2231+};
21372232
2138− // Don't fight an already-running local server against the same data dir
2139− // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2140− const active = yield* readActiveLocalServerManifest();
2141− if (active) {
2142− const status = yield* backend.status();
2143− if (status.registered && status.running && active.kind === "cli-daemon") {
2144− console.log(
2145− `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
2146− );
2147− return;
2148− }
2149− return yield* Effect.fail(
2150− new Error(
2151− [
2152− `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2153− `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2154− ].join("\n"),
2155− ),
2156− );
2157− }
2233+const installService = (port: number, commandName: string) =>
2234+ Effect.gen(function* () {
2235+ const command = `${cliPrefix} ${commandName}`;
2236+ if (isDevMode) {
2237+ return yield* Effect.fail(
2238+ new Error(
2239+ [
2240+ `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`,
2241+ `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2242+ ].join("\n"),
2243+ ),
2244+ );
2245+ }
21582246
2159− // The unit carries no secret: the supervised daemon mints/loads its bearer
2160− // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2161− // the same file — so reachability is the credential-free /api/health probe.
2247+ const backend = getServiceBackend();
2248+ if (!backend.automated) {
2249+ // Unsupported platforms surface their manual steps via the install error.
21622250 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2251+ return;
2252+ }
21632253
2164− const origin = supervisedServiceOrigin(port);
2165− const reachable = yield* waitForReachable({
2166− check: isServerReachable(origin),
2167− timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2168− intervalMs: DAEMON_BOOT_POLL_MS,
2169− });
2170− if (!reachable) {
2171− return yield* Effect.fail(
2172− new Error(
2173− [
2174− `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2175− `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2176− ].join("\n"),
2177− ),
2254+ const status = yield* backend.status();
2255+ const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2256+ const plan = planServiceInstall({
2257+ registered: status.registered,
2258+ running: status.running,
2259+ activeKind: active?.kind ?? null,
2260+ activePid: active?.pid ?? null,
2261+ servicePid: status.pid,
2262+ activeVersion: active?.owner.version ?? null,
2263+ activeExecutablePath: active?.owner.executablePath ?? null,
2264+ activePort: active ? portFromOrigin(active.connection.origin) : null,
2265+ requestedPort: port,
2266+ currentVersion: CLI_VERSION,
2267+ currentExecutablePath: process.execPath,
2268+ });
2269+
2270+ if (plan === "noop") {
2271+ const where = active ? ` at ${active.connection.origin} (pid ${active.pid})` : "";
2272+ console.log(`Executor background service is already running${where}.`);
2273+ console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2274+ return;
2275+ }
2276+
2277+ if (plan === "takeover-then-install") {
2278+ const replaced = yield* takeOverActiveLocalServer();
2279+ if (replaced) {
2280+ console.log(
2281+ `Replacing running Executor ${replaced.kind} at ${replaced.connection.origin} (pid ${replaced.pid})...`,
21782282 );
21792283 }
2284+ }
21802285
2181− console.log(`Executor is now running as a background service at ${origin}.`);
2182− console.log("It keeps serving after you quit the app and restarts on login.");
2183− console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);
2184− }),
2286+ const path = yield* PlatformPath.Path;
2287+ const dataDir = resolveExecutorDataDir(path);
2288+ const origin = supervisedServiceOrigin(port);
2289+ console.log("Installing Executor as a background service...");
2290+ console.log(`Service manager: ${serviceManagerName(backend.platform)}`);
2291+ console.log(`Web UI: ${origin}`);
2292+ console.log(`Data directory: ${dataDir}`);
2293+ console.log(`Logs: ${path.join(dataDir, "logs")}`);
2294+ console.log("");
2295+ console.log("Writing the service definition and starting Executor...");
2296+
2297+ // The unit carries no secret: the supervised daemon mints/loads its bearer
2298+ // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2299+ // the same file — so reachability is the credential-free /api/health probe.
2300+ yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2301+
2302+ console.log(`Waiting for Executor to become reachable at ${origin}...`);
2303+ const reachable = yield* waitForReachable({
2304+ check: isServerReachable(origin),
2305+ timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2306+ intervalMs: DAEMON_BOOT_POLL_MS,
2307+ });
2308+ if (!reachable) {
2309+ return yield* Effect.fail(
2310+ new Error(
2311+ [
2312+ `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2313+ `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2314+ ].join("\n"),
2315+ ),
2316+ );
2317+ }
2318+
2319+ console.log(`Executor is now running as a background service at ${origin}.`);
2320+ console.log("It keeps serving after you quit the app and restarts on login.");
2321+ console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2322+ });
2323+
2324+const serviceInstallCommand = Command.make(
2325+ "install",
2326+ {
2327+ port: servicePortOption(),
2328+ },
2329+ ({ port }) => installService(port, "service install"),
21852330 ).pipe(
21862331 Command.withDescription("Install and start Executor as an OS-supervised background service"),
21872332 );
@@ -2189,7 +2334,21 @@ const serviceInstallCommand = Command.make( · 1 unmodified line
21892334 const serviceUninstallCommand = Command.make("uninstall", {}, () =>
21902335 Effect.gen(function* () {
21912336 const backend = getServiceBackend();
2337+ const wasRunning = backend.automated
2338+ ? yield* backend.status().pipe(
2339+ Effect.map((status) => status.running),
2340+ Effect.catchCause(() => Effect.succeed(false)),
2341+ )
2342+ : false;
21922343 yield* backend.uninstall();
2344+ if (wasRunning) {
2345+ const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" });
2346+ if (stopped) {
2347+ console.log(
2348+ `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`,
2349+ );
2350+ }
2351+ }
21932352 console.log("Executor background service uninstalled.");
21942353 }),
21952354 ).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));
@@ -2240,6 +2399,16 @@ const serviceCommand = Command.make("service").pipe( · 44 unmodified lines
22402399 Command.withDescription("Manage the OS-supervised background service"),
22412400 );
22422401
2402+const installCommand = Command.make(
2403+ "install",
2404+ {
2405+ port: servicePortOption(),
2406+ },
2407+ ({ port }) => installService(port, "install"),
2408+).pipe(
2409+ Command.withDescription("Install and start Executor as an OS-supervised background service"),
2410+);
2411+
22432412 // ---------------------------------------------------------------------------
22442413 // Root command
22452414 // ---------------------------------------------------------------------------
@@ -2263,17 +2432,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines
22632432 execFile(cmd, [...args], () => {});
22642433 });
22652434
2266−/**
2267− * `executor open` — the friendly way back in. Reads the running local server's
2268− * manifest and opens the browser straight to its `?_token=` URL, so the user
2269− * never has to copy a bearer token out of a terminal or auth.json by hand.
2270− */
2271−const openCommand = Command.make("open", {}, () =>
2435+const printNoRunningLocalWebApp = (): void => {
2436+ console.log("Executor is not running.");
2437+ console.log("");
2438+ console.log("Install and start the background service:");
2439+ console.log(` ${cliPrefix} install`);
2440+ console.log("");
2441+ console.log("Then open the web UI:");
2442+ console.log(` ${cliPrefix} web`);
2443+ console.log("");
2444+ console.log("For a temporary foreground server:");
2445+ console.log(` ${cliPrefix} web --foreground`);
2446+};
2447+
2448+const openRunningLocalWebApp = (): Effect.Effect<
2449+ void,
2450+ never,
2451+ FileSystem.FileSystem | PlatformPath.Path
2452+> =>
22722453 Effect.gen(function* () {
2273− const manifest = yield* readLocalServerManifest();
2274− if (!manifest || !isPidAlive(manifest.pid)) {
2275− console.log("No local Executor server is running.");
2276− console.log(`Start one with: ${cliPrefix} web`);
2454+ const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2455+ if (!manifest) {
2456+ printNoRunningLocalWebApp();
22772457 return;
22782458 }
22792459 const { origin, auth } = manifest.connection;
@@ -2281,8 +2461,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line
22812461 const url = token ? `${origin}/?_token=${token}` : origin;
22822462 console.log(`Opening ${url}`);
22832463 yield* openInBrowser(url);
2284− }),
2285−).pipe(
2464+ });
2465+
2466+/**
2467+ * `executor open` — the friendly way back in. Reads the running local server's
2468+ * manifest and opens the browser straight to its `?_token=` URL, so the user
2469+ * never has to copy a bearer token out of a terminal or auth.json by hand.
2470+ */
2471+const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe(
22862472 Command.withDescription("Open the running Executor web app in your browser, already signed in"),
22872473 );
22882474
@@ -2291,6 +2477,7 @@ const root = Command.make("executor").pipe( · 2 unmodified lines
22912477 callCommand,
22922478 resumeCommand,
22932479 toolsCommand,
2480+ installCommand,
22942481 serverCommand,
22952482 webCommand,
22962483 daemonCommand,
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+60−23
@@ -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)
@@ -270,8 +291,12 @@ const makeLaunchdBackend = (): ServiceBackend => { · 105 unmodified lines
270291 yield* fs.writeFileString(plistFile, plist, { mode: 0o600 });
271292
272293 // Re-bootstrap cleanly: a stale registration from a prior install would
273− // make `bootstrap` fail with "service already loaded".
294+ // make `bootstrap` fail with "service already loaded". `service
295+ // uninstall` also records the label as disabled in launchd's override
296+ // database; clear that before bootstrapping or a reinstall can fail with
297+ // launchctl's generic "Bootstrap failed: 5" error.
274298 yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
299+ yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
275300 const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]);
276301 if (bootstrap.code !== 0) {
277302 return yield* Effect.fail(
@@ -280,7 +305,6 @@ const makeLaunchdBackend = (): ServiceBackend => { · 2 unmodified lines
280305 ),
281306 );
282307 }
283− yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
284308 }),
285309 uninstall: () =>
286310 Effect.gen(function* () {
@@ -340,11 +364,24 @@ export interface SystemdUnitOptions { · 53 unmodified lines
340364 readonly stderrPath: string;
341365 }
342366
367+const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
368+
369+const systemdQuote = (value: string): string => {
370+ if (SYSTEMD_BARE_VALUE.test(value)) return value;
371+ const escaped = value
372+ .replaceAll("\\", "\\\\")
373+ .replaceAll('"', '\\"')
374+ .replaceAll("\n", "\\n")
375+ .replaceAll("\r", "\\r")
376+ .replaceAll("\t", "\\t");
377+ return `"${escaped}"`;
378+};
379+
343380 /** Render a systemd --user unit. Pure (snapshot-tested). */
344381 export const generateSystemdUnit = (options: SystemdUnitOptions): string => {
345− const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
382+ const execStart = options.execStart.map(systemdQuote).join(" ");
346383 const env = Object.entries(options.environment)
347− .map(([key, value]) => `Environment=${key}=${value}`)
384+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
348385 .join("\n");
349386 return `[Unit]
350387 Description=Executor supervised daemon
@@ -354,9 +391,9 @@ After=default.target · 3 unmodified lines
354391 Type=simple
355392 ExecStart=${execStart}
356393 ${env}
357−WorkingDirectory=${options.workingDirectory}
358−StandardOutput=append:${options.stdoutPath}
359−StandardError=append:${options.stderrPath}
394+WorkingDirectory=${systemdQuote(options.workingDirectory)}
395+StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)}
396+StandardError=${systemdQuote(`append:${options.stderrPath}`)}
360397 Restart=on-failure
361398 RestartSec=5s
362399
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+96−21
@@ -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) => {
@@ -415,12 +478,24 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 28 unmodified lines
415478 };
416479
417480 const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {
418− // A supervised daemon isn't ours to restart — just reload the window against
419− // the same endpoint instead of tearing down a process we don't own.
481+ // A supervised daemon owns its own process lifetime. Re-installing the unit
482+ // rewrites settings such as the configured port, then launchd restarts it.
420483 if (connection?.supervisedDaemon) {
484+ const port = getServerSettings().port;
485+ await installSupervisedService({
486+ port,
487+ dataDir: DESKTOP_DATA_DIR,
488+ });
489+ const next = await waitForSupervisedAttach(30_000, { port });
490+ if (!next) {
491+ // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call
492+ throw new Error("Supervised daemon failed to restart — see Settings");
493+ }
494+ connection = next;
495+ installBearerAuthHeader(next.baseUrl, next.authToken);
421496 const window = liveMainWindow();
422− if (window) await window.loadURL(connection.baseUrl);
423− return toDesktopServerConnection(connection);
497+ if (window) await window.loadURL(webUrlForConnection(next));
498+ return toDesktopServerConnection(next);
424499 }
425500 if (connection) {
426501 await stopConnection(connection);
@@ -434,7 +509,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 7 unmodified lines
434509 connection = next;
435510 installBearerAuthHeader(next.baseUrl, next.authToken);
436511 const window = liveMainWindow();
437− if (window) await window.loadURL(next.baseUrl);
512+ if (window) await window.loadURL(webUrlForConnection(next));
438513 return toDesktopServerConnection(next);
439514 };
440515
@@ -446,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec · 5 unmodified lines
446521 key: "desktop-sidecar",
447522 origin: conn.baseUrl,
448523 apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,
449− displayName: "Desktop sidecar",
524+ displayName: "Local Executor",
450525 });
451526
452527 const registerIpcHandlers = () => {
@@ -475,14 +550,14 @@ const registerIpcHandlers = () => { · 22 unmodified lines
475550 connection = active;
476551 installBearerAuthHeader(active.baseUrl, active.authToken);
477552 const window = liveMainWindow();
478− if (window) await window.loadURL(active.baseUrl);
553+ if (window) await window.loadURL(webUrlForConnection(active));
479554 return toDesktopServerConnection(active);
480555 }
481556 return restartSidecarAndReload();
482557 });
483− // Background-service control surface (macOS) — lets a Settings toggle enable
484− // or disable the supervised daemon. Disabling tears down the service and
485− // 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.
486561 ipcMain.handle("executor:service:status", () => supervisedServiceStatus());
487562 ipcMain.handle(
488563 "executor:service:set-enabled",
@@ -501,7 +576,7 @@ const registerIpcHandlers = () => { · 12 unmodified lines
501576 armSupervisedMonitor();
502577 installBearerAuthHeader(next.baseUrl, next.authToken);
503578 const window = liveMainWindow();
504− if (window) await window.loadURL(next.baseUrl);
579+ if (window) await window.loadURL(webUrlForConnection(next));
505580 }
506581 return true;
507582 }
@@ -762,7 +837,7 @@ const boot = async () => { · 254 unmodified lines
762837 // Prefer an OS-supervised daemon: attach to one that's running, kick one
763838 // that's installed, or offer to install on first run. Quitting the app then
764839 // leaves MCP serving. This is also the clean handoff that replaces the old
765− // "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;
766841 // dev and unsupported platforms keep managed-spawn.
767842 if (app.isPackaged) {
768843 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−182
@@ -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,84 +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− await runCommand("launchctl", ["bootout", serviceTarget(uid)]);
209− const bootstrap = await runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath()]);
210− if (bootstrap.code !== 0) {
211− // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to the boot flow
212− throw new Error(
213− `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`,
214− );
115+ if (result.code !== 0) {
116+ throw new Error((result.stderr || result.stdout).trim() || "`executor install` failed.");
215117 }
216− await runCommand("launchctl", ["enable", serviceTarget(uid)]);
217− serviceLog.info(`installed supervised service on port ${opts.port}`);
118+ serviceLog.info(`installed supervised service via bundled executor on port ${opts.port}`);
218119 };
219120
220121 export const uninstallSupervisedService = async (dataDir: string): Promise<void> => {
221− const uid = currentUid();
222− await runCommand("launchctl", ["bootout", serviceTarget(uid)]);
223− await runCommand("launchctl", ["disable", serviceTarget(uid)]);
224− rmSync(plistPath(), { force: true });
225− // Clean up a legacy service.key from a pre-bearer install (best-effort).
226− rmSync(join(dataDir, "server-control", "service.key"), { force: true });
227− 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");
228130 };
229131
230−/** Restart the supervised daemon atomically (kill + relaunch via launchd). */
231132 export const restartSupervisedService = async (): Promise<void> => {
232− 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+ }
233143 };
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+105−64
@@ -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
@@ -408,25 +440,27 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 30 unmodified lines
408440 });
409441 }
410442
411−/**
412− * Probe whether an HTTP server is listening at `origin`. Any HTTP response —
413− * even 401/404 — means a server is up; only a network error or timeout counts
414− * as unreachable. Auth is attached so a 401 still confirms liveness cleanly.
415− */
416−const isDaemonReachable = async (origin: string, authToken: string): Promise<boolean> => {
417− const controller = new AbortController();
418− const timer = setTimeout(() => controller.abort(), 1500);
419− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal
420− try {
421− const headers: Record<string, string> = {};
422− if (authToken) headers.Authorization = `Bearer ${authToken}`;
423− await fetch(origin, { signal: controller.signal, headers, redirect: "manual" });
424− return true;
425− } catch {
426− return false;
427− } finally {
428− clearTimeout(timer);
443+/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
444+const isDaemonReachable = async (origin: string): Promise<boolean> => {
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);
429462 }
463+ return false;
430464 };
431465
432466 /**
@@ -452,7 +486,11 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 19 unmodified lines
452486 const origin = manifest.connection.origin;
453487 const auth = manifest.connection.auth;
454488 const authToken = auth && auth.kind === "bearer" ? auth.token : "";
455− if (!(await isDaemonReachable(origin, authToken))) 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+ }
456494
457495 const url = new URL(origin);
458496 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);
@@ -464,6 +502,9 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 5 unmodified lines
464502 authToken,
465503 child: null,
466504 supervisedDaemon: true,
505+ ownerVersion: manifest.owner.version,
506+ ownerClient: manifest.owner.client,
507+ ownerExecutablePath: manifest.owner.executablePath,
467508 };
468509 }
469510
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+9−9
@@ -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");
@@ -63,7 +61,7 @@ if (sentryDsn) { · 40 unmodified lines
6361 });
6462 }
6563
66−import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
64+import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6765 import { homedir } from "node:os";
6866 import {
6967 normalizeExecutorServerConnection,
@@ -116,7 +114,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines
116114 executablePath: process.execPath || null,
117115 },
118116 }),
117+ { mode: 0o600 },
119118 );
119+ chmodSync(manifestPath, 0o600);
120120 };
121121
122122 const removeOwnManifest = () => {
apps/host-selfhost/src/mcp/index.ts+35−6
@@ -1,7 +1,12 @@
11 import { Effect, Layer } from "effect";
22
33 import { IdentityProvider } from "@executor-js/api/server";
4−import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";
4+import type {
5+ McpAuthProvider,
6+ McpErrorReporter,
7+ McpSessionStore,
8+ Principal,
9+} from "@executor-js/host-mcp";
510
611 import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
712 import type { SelfHostDbHandle } from "../db/self-host-db";
@@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines
6469 const jsonResponse = (value: unknown, status: number): Response =>
6570 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
6671
72+const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
73+ (role ?? "user")
74+ .split(",")
75+ .map((r) => r.trim())
76+ .filter((r) => r.length > 0);
77+
78+type BetterAuthSession = NonNullable<
79+ Awaited<ReturnType<BetterAuthHandle["auth"]["api"]["getSession"]>>
80+>;
81+
82+const principalFromSession = (
83+ resolved: BetterAuthSession,
84+ betterAuth: BetterAuthHandle,
85+): Principal => ({
86+ accountId: resolved.user.id,
87+ organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId,
88+ organizationName: betterAuth.organizationName,
89+ email: resolved.user.email,
90+ name: resolved.user.name ?? null,
91+ avatarUrl: resolved.user.image ?? null,
92+ roles: parseRoles(resolved.user.role ?? null),
93+});
94+
6795 /**
6896 * Gate the browser-approval endpoints behind a valid Better Auth session (the
6997 * console page calls them with the user's cookie), then delegate to the
70− * in-process store's paused/resume handlers. Single-tenant: any authenticated
71− * user of the one org may act on a session it still holds — the store confirms
72− * the execution belongs to the addressed session before recording.
98+ * in-process store's paused/resume handlers with the resolved principal so the
99+ * store can enforce MCP session ownership before exposing or recording a
100+ * browser-approval decision.
73101 */
74102 const makeApprovalHandler =
75103 (
@@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines
85113 }).pipe(Effect.orElseSucceed(() => null)),
86114 );
87115 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);
116+ const principal = principalFromSession(session, betterAuth);
88117
89118 return (
90− (await store.handlePausedRequest(request)) ??
91− (await store.handleApprovalRequest(request)) ??
119+ (await store.handlePausedRequest(request, principal)) ??
120+ (await store.handleApprovalRequest(request, principal)) ??
92121 jsonResponse({ error: "Not found" }, 404)
93122 );
94123 };
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({
apps/local/vite.config.ts+31−13
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
55 import { defineConfig, type Plugin } from "vite";
66 import appPlugin from "@executor-js/app/vite";
77 import { loadOrMintLocalAuthToken } from "./src/auth";
8+import { consumeOAuthResult } from "./src/oauth-result-store";
89 import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
910
1011 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
@@ -114,20 +115,37 @@ function executorApiPlugin(): Plugin { · 103 unmodified lines
114115 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
115116 }
116117
117− // Strip /api prefix for Effect handlers
118− const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl;
119−
120118 const hasBody = req.method !== "GET" && req.method !== "HEAD";
121− const webRequest = new Request(new URL(url, origin), {
122− method: req.method,
123− headers,
124− body: hasBody ? Readable.toWeb(req) : undefined,
125− duplex: hasBody ? "half" : undefined,
126− } as RequestInit);
127−
128− const response = isMcp
129− ? await handlers.mcp.handleRequest(webRequest)
130− : await handlers.api.handler(webRequest);
119+ const webRequest = (url: string): Request =>
120+ new Request(new URL(url, origin), {
121+ method: req.method,
122+ headers,
123+ body: hasBody ? Readable.toWeb(req) : undefined,
124+ duplex: hasBody ? "half" : undefined,
125+ } as RequestInit);
126+
127+ let response: Response;
128+ if (isMcp) {
129+ response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130+ } else if (pathOnly === "/api/health" && req.method === "GET") {
131+ response = new Response("ok", { headers: { "content-type": "text/plain" } });
132+ } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
133+ const handler =
134+ req.method === "GET"
135+ ? handlers.mcp.handlePausedRequest
136+ : handlers.mcp.handleApprovalRequest;
137+ response = await handler(webRequest(rawUrl));
138+ } else {
139+ const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);
140+ if (awaitMatch && req.method === "GET") {
141+ response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {
142+ headers: { "content-type": "application/json" },
143+ });
144+ } else {
145+ // Strip /api prefix for Effect handlers.
146+ response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/"));
147+ }
148+ }
131149
132150 res.statusCode = response.status;
133151 response.headers.forEach((v, k) => res.setHeader(k, v));
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+213−0
@@ -0,0 +1,213 @@
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 CLI VM targets where the test worker can SSH into the guest
5+// that globalsetup provisioned.
6+import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
7+import { promisify } from "node:util";
8+
9+import { expect } 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+type GuestOs = "macos" | "linux" | "windows";
31+
32+const guestOs = (): GuestOs => {
33+ const os = process.env.E2E_VM_OS;
34+ if (os === "macos" || os === "linux" || os === "windows") return os;
35+ throw new Error(`Unsupported E2E_VM_OS: ${os ?? "<unset>"}`);
36+};
37+
38+const sshInvocation = (command: string): { command: string; args: ReadonlyArray<string> } => {
39+ const host = process.env.E2E_CLI_VM_HOST;
40+ if (!host) throw new Error("E2E_CLI_VM_HOST is not set");
41+ const os = guestOs();
42+ const wrapped =
43+ os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command;
44+ const keyPath = process.env.E2E_CLI_SSH_KEY;
45+ const user = os === "windows" ? "Administrator" : "admin";
46+ return keyPath
47+ ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] }
48+ : {
49+ command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass",
50+ args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped],
51+ };
52+};
53+
54+const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => {
55+ const invocation = sshInvocation(command);
56+ try {
57+ const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], {
58+ maxBuffer: 32 * 1024 * 1024,
59+ });
60+ return { stdout, stderr, code: 0 };
61+ } catch (error) {
62+ const err = error as { stdout?: string; stderr?: string; code?: number };
63+ return {
64+ stdout: err.stdout ?? "",
65+ stderr: err.stderr ?? "",
66+ code: typeof err.code === "number" ? err.code : 1,
67+ };
68+ }
69+};
70+
71+const executorPath = (): string => {
72+ const dir = process.env.E2E_CLI_BIN_DIR ?? (guestOs() === "windows" ? "C:/ed" : "~/ed");
73+ return guestOs() === "windows" ? `${dir}/executor.exe` : `${dir}/executor`;
74+};
75+
76+const healthStatusCommand = (): string =>
77+ guestOs() === "windows"
78+ ? `try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 'http://127.0.0.1:${PORT}/api/health'; [string]$r.StatusCode } catch { '000' }`
79+ : `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`;
80+
81+const waitForGuestHealth = async (expected: boolean): Promise<boolean> => {
82+ const deadline = Date.now() + 30_000;
83+ for (;;) {
84+ const result = await ssh(healthStatusCommand());
85+ const healthy = result.stdout.trim() === "200";
86+ if (healthy === expected) return true;
87+ if (Date.now() >= deadline) return false;
88+ await new Promise((resolve) => setTimeout(resolve, 500));
89+ }
90+};
91+
92+const listenerPid = async (): Promise<string> =>
93+ (
94+ await ssh(
95+ guestOs() === "windows"
96+ ? `$c = Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $c) { [string]$c.OwningProcess }`
97+ : `lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`,
98+ )
99+ ).stdout.trim();
100+
101+const uninstallServiceCommand = (exe: string): string =>
102+ guestOs() === "windows"
103+ ? `& '${exe}' service uninstall *> 'C:/Windows/Temp/takeover-uninstall.log'; exit 0`
104+ : `${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`;
105+
106+interface PredecessorHandle {
107+ readonly close: () => void;
108+ readonly diagnostics: () => Promise<string>;
109+}
110+
111+const appendChunk = (existing: string, chunk: Buffer): string =>
112+ `${existing}${chunk.toString("utf8")}`.slice(-16_000);
113+
114+const windowsPredecessorCommand = (exe: string): string =>
115+ `& '${exe}' daemon run --foreground --port ${PORT} --hostname 127.0.0.1`;
116+
117+const startPredecessor = async (exe: string): Promise<PredecessorHandle> => {
118+ if (guestOs() !== "windows") {
119+ await ssh(
120+ `nohup ${exe} daemon run --foreground --port ${PORT} --hostname 127.0.0.1 >/tmp/takeover-predecessor.log 2>&1 &`,
121+ );
122+ return {
123+ close: () => {},
124+ diagnostics: async () =>
125+ (await ssh("cat /tmp/takeover-predecessor.log 2>/dev/null || true")).stdout.trim(),
126+ };
127+ }
128+
129+ const invocation = sshInvocation(windowsPredecessorCommand(exe));
130+ const child: ChildProcessWithoutNullStreams = spawn(invocation.command, [...invocation.args], {
131+ stdio: ["ignore", "pipe", "pipe"],
132+ });
133+ let stdout = "";
134+ let stderr = "";
135+ let exit: string | null = null;
136+ child.stdout.on("data", (chunk: Buffer) => {
137+ stdout = appendChunk(stdout, chunk);
138+ });
139+ child.stderr.on("data", (chunk: Buffer) => {
140+ stderr = appendChunk(stderr, chunk);
141+ });
142+ child.on("error", (error) => {
143+ exit = `spawn error: ${error.message}`;
144+ });
145+ child.on("exit", (code, signal) => {
146+ exit = `exit code ${code ?? "<null>"} signal ${signal ?? "<null>"}`;
147+ });
148+
149+ return {
150+ close: () => {
151+ if (!child.killed) child.kill();
152+ },
153+ diagnostics: async () =>
154+ [`predecessor ssh: ${exit ?? "still running"}`, `stdout:\n${stdout}`, `stderr:\n${stderr}`]
155+ .join("\n")
156+ .trim(),
157+ };
158+};
159+
160+const installServiceCommand = (exe: string): string =>
161+ guestOs() === "windows"
162+ ? `& '${exe}' service install --port ${PORT}; exit $LASTEXITCODE`
163+ : `${exe} service install --port ${PORT}`;
164+
165+const processAliveCommand = (pid: string): string =>
166+ guestOs() === "windows"
167+ ? `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }`
168+ : `kill -0 ${pid} 2>/dev/null && echo alive || echo dead`;
169+
170+const restoreServiceCommand = (exe: string): string =>
171+ guestOs() === "windows"
172+ ? `& '${exe}' service install --port ${PORT} *> 'C:/Windows/Temp/takeover-restore.log'; exit 0`
173+ : `${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`;
174+
175+scenario(
176+ "CLI service install · takes over a running predecessor daemon",
177+ { timeout: 180_000 },
178+ Effect.promise(async () => {
179+ const exe = executorPath();
180+ let predecessor: PredecessorHandle | null = null;
181+ try {
182+ await ssh(uninstallServiceCommand(exe));
183+ expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe(
184+ true,
185+ );
186+
187+ predecessor = await startPredecessor(exe);
188+ const predecessorReady = await waitForGuestHealth(true);
189+ expect(
190+ predecessorReady,
191+ `predecessor daemon became reachable\n${await predecessor.diagnostics()}`,
192+ ).toBe(true);
193+ const predecessorPid = await listenerPid();
194+ expect(predecessorPid, "predecessor owns the service port").not.toBe("");
195+
196+ const install = await ssh(installServiceCommand(exe));
197+ expect(
198+ install.code,
199+ `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`,
200+ ).toBe(0);
201+ expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true);
202+
203+ const ownerPid = await listenerPid();
204+ const predecessorAlive = (await ssh(processAliveCommand(predecessorPid))).stdout.trim();
205+ expect(predecessorAlive, "predecessor process was stopped").toBe("dead");
206+ expect(ownerPid, "the service now owns the port").not.toBe("");
207+ expect(ownerPid, "the service is a different process").not.toBe(predecessorPid);
208+ } finally {
209+ predecessor?.close();
210+ await ssh(restoreServiceCommand(exe));
211+ }
212+ }),
213+);
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+934−0
@@ -0,0 +1,934 @@
1+// Packaged desktop supervised-daemon regressions. These run against the real
2+// electron-builder bundle and its bundled executor because the supervised attach
3+// path is production-only (`app.isPackaged`).
4+import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process";
5+import {
6+ chmodSync,
7+ existsSync,
8+ mkdirSync,
9+ mkdtempSync,
10+ readFileSync,
11+ rmSync,
12+ statSync,
13+ writeFileSync,
14+} from "node:fs";
15+import { createServer, type IncomingMessage } from "node:http";
16+import net from "node:net";
17+import { homedir, tmpdir } from "node:os";
18+import { dirname, join } from "node:path";
19+import { promisify } from "node:util";
20+
21+import { expect, it } from "@effect/vitest";
22+import { Effect } from "effect";
23+import {
24+ normalizeExecutorServerConnection,
25+ serializeExecutorLocalServerManifest,
26+} from "@executor-js/sdk/shared";
27+
28+import { scenario } from "../src/scenario";
29+import { RunDir } from "../src/services";
30+import { waitForHttp } from "../setup/boot";
31+
32+const execFileAsync = promisify(execFile);
33+const SERVICE_LABEL = "sh.executor.daemon";
34+
35+interface PackagedExecutorBridge {
36+ readonly getSettings: () => Promise<{ readonly port: number }>;
37+ readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>;
38+ readonly restartServer: () => Promise<unknown>;
39+ readonly getServerConnection: () => Promise<{ readonly origin: string } | null>;
40+}
41+
42+interface PackagedApp {
43+ readonly child: ChildProcess;
44+ cdp: CdpPage;
45+ readonly debugPort: string;
46+ readonly output: () => string;
47+}
48+
49+interface CdpResponse<T> {
50+ readonly id: number;
51+ readonly result?: T;
52+ readonly error?: { readonly message?: string; readonly data?: string };
53+}
54+
55+interface CdpEvaluateResult {
56+ readonly result: { readonly value?: unknown };
57+ readonly exceptionDetails?: unknown;
58+}
59+
60+interface CdpTarget {
61+ readonly type: string;
62+ readonly url: string;
63+ readonly webSocketDebuggerUrl?: string;
64+}
65+
66+class CdpPage {
67+ private nextId = 1;
68+ private readonly pending = new Map<
69+ number,
70+ {
71+ readonly resolve: (value: unknown) => void;
72+ readonly reject: (error: Error) => void;
73+ }
74+ >();
75+
76+ private constructor(private readonly socket: WebSocket) {
77+ socket.addEventListener("message", (event) => {
78+ const data = event.data;
79+ if (typeof data !== "string") return;
80+ const message = JSON.parse(data) as CdpResponse<unknown>;
81+ if (!message.id) return;
82+ const pending = this.pending.get(message.id);
83+ if (!pending) return;
84+ this.pending.delete(message.id);
85+ if (message.error) {
86+ pending.reject(new Error(message.error.message ?? "CDP command failed"));
87+ return;
88+ }
89+ pending.resolve(message.result);
90+ });
91+ socket.addEventListener("close", () => {
92+ for (const [, pending] of this.pending) {
93+ pending.reject(new Error("CDP socket closed"));
94+ }
95+ this.pending.clear();
96+ });
97+ }
98+
99+ static connect = (url: string): Promise<CdpPage> =>
100+ new Promise((resolve, reject) => {
101+ const socket = new WebSocket(url);
102+ const timer = setTimeout(() => {
103+ socket.close();
104+ // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
105+ reject(new Error(`Timed out connecting to page CDP target ${url}`));
106+ }, 30_000);
107+ socket.addEventListener(
108+ "open",
109+ () => {
110+ clearTimeout(timer);
111+ resolve(new CdpPage(socket));
112+ },
113+ { once: true },
114+ );
115+ socket.addEventListener(
116+ "error",
117+ () => {
118+ clearTimeout(timer);
119+ // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter
120+ reject(new Error(`Failed to connect to page CDP target ${url}`));
121+ },
122+ { once: true },
123+ );
124+ });
125+
126+ command = async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => {
127+ const id = this.nextId;
128+ this.nextId += 1;
129+ const result = new Promise<T>((resolve, reject) => {
130+ this.pending.set(id, {
131+ resolve: (value) => resolve(value as T),
132+ reject,
133+ });
134+ });
135+ this.socket.send(JSON.stringify({ id, method, params }));
136+ return result;
137+ };
138+
139+ evaluate = async <T>(expression: string): Promise<T> => {
140+ const result = await this.command<CdpEvaluateResult>("Runtime.evaluate", {
141+ expression,
142+ awaitPromise: true,
143+ returnByValue: true,
144+ });
145+ if (result.exceptionDetails) {
146+ throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
147+ }
148+ return result.result.value as T;
149+ };
150+
151+ waitForText = async (text: string, timeoutMs: number): Promise<void> => {
152+ const deadline = Date.now() + timeoutMs;
153+ const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`;
154+ for (;;) {
155+ if (await this.evaluate<boolean>(expression).catch(() => false)) return;
156+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`);
157+ await new Promise((resolve) => setTimeout(resolve, 250));
158+ }
159+ };
160+
161+ waitForExpression = async (
162+ expression: string,
163+ timeoutMs: number,
164+ description: string,
165+ ): Promise<void> => {
166+ const deadline = Date.now() + timeoutMs;
167+ for (;;) {
168+ if (await this.evaluate<boolean>(`Boolean(${expression})`).catch(() => false)) return;
169+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`);
170+ await new Promise((resolve) => setTimeout(resolve, 250));
171+ }
172+ };
173+
174+ textPresent = async (text: string): Promise<boolean> =>
175+ this.evaluate<boolean>(`document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`);
176+
177+ setViewport = async (width: number, height: number): Promise<void> => {
178+ await this.command("Emulation.setDeviceMetricsOverride", {
179+ width,
180+ height,
181+ deviceScaleFactor: 1,
182+ mobile: false,
183+ });
184+ };
185+
186+ wheel = async (x: number, y: number, deltaY: number): Promise<void> => {
187+ await this.command("Input.dispatchMouseEvent", {
188+ type: "mouseWheel",
189+ x,
190+ y,
191+ deltaX: 0,
192+ deltaY,
193+ });
194+ };
195+
196+ screenshot = async (path: string): Promise<void> => {
197+ const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", {
198+ format: "png",
199+ fromSurface: true,
200+ });
201+ writeFileSync(path, Buffer.from(result.data, "base64"));
202+ };
203+
204+ close = (): void => {
205+ this.socket.close();
206+ };
207+}
208+
209+declare global {
210+ interface Window {
211+ readonly executor: PackagedExecutorBridge;
212+ }
213+}
214+
215+const appExe = process.env.E2E_DESKTOP_APP_EXE;
216+const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN;
217+
218+const guiAvailable = (): boolean => {
219+ if (process.platform === "darwin") {
220+ try {
221+ return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua";
222+ } catch {
223+ return false;
224+ }
225+ }
226+ if (process.platform === "linux")
227+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
228+ return true;
229+};
230+
231+const packagedSingleInstanceAvailable = (): boolean => {
232+ if (process.platform !== "darwin" || !appExe) return true;
233+ try {
234+ const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], {
235+ encoding: "utf8",
236+ })
237+ .split("\n")
238+ .filter(Boolean);
239+ return !lines.some((line) => !line.includes(appExe));
240+ } catch {
241+ return true;
242+ }
243+};
244+
245+const requireBundle = (): { readonly app: string; readonly executor: string } => {
246+ if (!appExe || !executorBin) {
247+ throw new Error(
248+ "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?",
249+ );
250+ }
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+ }
341+};
342+
343+const freePort = (): Promise<number> =>
344+ new Promise((resolve, reject) => {
345+ const srv = net.createServer();
346+ srv.on("error", reject);
347+ srv.listen(0, "127.0.0.1", () => {
348+ const port = (srv.address() as net.AddressInfo).port;
349+ srv.close(() => resolve(port));
350+ });
351+ });
352+
353+interface DaemonStart {
354+ readonly child: ChildProcess;
355+ readonly ready: boolean;
356+ readonly stderr: string;
357+}
358+
359+const startSupervisedDaemon = (
360+ env: NodeJS.ProcessEnv,
361+ port: number,
362+ hostname = "127.0.0.1",
363+): Promise<DaemonStart> =>
364+ new Promise((resolve) => {
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+ );
371+ let stderr = "";
372+ let settled = false;
373+ const settle = (ready: boolean) => {
374+ if (settled) return;
375+ settled = true;
376+ resolve({ child, ready, stderr });
377+ };
378+ const timer = setTimeout(() => settle(false), 60_000);
379+ child.stdout.on("data", (chunk: Buffer) => {
380+ if (/Daemon ready on http:\/\//.test(chunk.toString())) {
381+ clearTimeout(timer);
382+ settle(true);
383+ }
384+ });
385+ child.stderr.on("data", (chunk: Buffer) => {
386+ stderr += chunk.toString();
387+ });
388+ child.on("exit", () => {
389+ clearTimeout(timer);
390+ settle(false);
391+ });
392+ });
393+
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 });
615+ }
616+};
617+
618+scenario(
619+ "Desktop packaged supervised daemon · server manifest is owner-only",
620+ { timeout: 180_000 },
621+ Effect.promise(async () => {
622+ requireBundle();
623+ const home = mkdtempSync(join(tmpdir(), "executor-pkg-manifest-mode-"));
624+ const dataDir = join(home, ".executor");
625+ const manifestPath = join(dataDir, "server-control", "server.json");
626+ const port = await freePort();
627+ let daemon: ChildProcess | undefined;
628+ const previousUmask = process.umask(0o022);
629+ try {
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+ );
641+ daemon = started.child;
642+ expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(
643+ true,
644+ );
645+ await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });
646+
647+ const mode = statSync(manifestPath).mode & 0o777;
648+ expect(
649+ mode.toString(8).padStart(3, "0"),
650+ "server.json embeds the bearer and must be owner read/write only",
651+ ).toBe("600");
652+ } finally {
653+ process.umask(previousUmask);
654+ daemon?.kill("SIGTERM");
655+ rmSync(home, { recursive: true, force: true });
656+ }
657+ }),
658+);
659+
660+if (!guiAvailable() || !packagedSingleInstanceAvailable()) {
661+ it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {});
662+} else {
663+ scenario(
664+ "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer",
665+ { timeout: 240_000 },
666+ Effect.promise(() => runStaleManifestProbe()),
667+ );
668+
669+ scenario(
670+ "Desktop packaged supervised settings · changing the port moves the active daemon",
671+ { timeout: 300_000 },
672+ Effect.gen(function* () {
673+ const runDir = yield* RunDir;
674+ yield* Effect.promise(() => runSupervisedPortSetting(runDir));
675+ }),
676+ );
677+
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+}
687+
688+const runStaleManifestProbe = async () => {
689+ const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-"));
690+ const dataDir = join(home, ".executor");
691+ const controlDir = join(dataDir, "server-control");
692+ const manifestPath = join(controlDir, "server.json");
693+ const token = "stale-manifest-leaked-token";
694+ const launchdSnapshot = captureLaunchdService();
695+ const requests: Array<{ readonly url: string; readonly authorization: string | null }> = [];
696+ let resolveFirst!: () => void;
697+ const firstRequest = new Promise<void>((resolve) => {
698+ resolveFirst = resolve;
699+ });
700+ const server = createServer((req: IncomingMessage, res) => {
701+ requests.push({
702+ url: req.url ?? "/",
703+ authorization: req.headers.authorization ?? null,
704+ });
705+ resolveFirst();
706+ res.writeHead(200, { "content-type": "text/html" });
707+ res.end("<!doctype html><title>stale daemon</title><body>stale daemon</body>");
708+ });
709+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
710+ const port = (server.address() as net.AddressInfo).port;
711+ let appProcess: ChildProcess | undefined;
712+ let appOutput = "";
713+
714+ try {
715+ mkdirSync(controlDir, { recursive: true });
716+ writeFileSync(
717+ join(controlDir, "server.json"),
718+ serializeExecutorLocalServerManifest({
719+ version: 1,
720+ kind: "cli-daemon",
721+ pid: process.pid,
722+ startedAt: new Date().toISOString(),
723+ dataDir,
724+ scopeDir: dataDir,
725+ connection: normalizeExecutorServerConnection({
726+ origin: `http://127.0.0.1:${port}`,
727+ displayName: "Stale daemon",
728+ auth: { kind: "bearer", token },
729+ }),
730+ owner: { client: "cli", version: null, executablePath: null },
731+ }),
732+ { mode: 0o600 },
733+ );
734+
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+
745+ const probed = await Promise.race([
746+ firstRequest.then(() => true),
747+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)),
748+ ]);
749+ expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true);
750+
751+ expect(
752+ requests[0]?.authorization ?? null,
753+ "the stale-manifest reachability probe must not disclose the saved bearer",
754+ ).toBeNull();
755+
756+ const manifestRemoved = await waitUntil(() => !existsSync(manifestPath), 15_000);
757+ expect(
758+ manifestRemoved,
759+ "a live pid with a failed health probe must be removed before desktop falls back",
760+ ).toBe(true);
761+ } finally {
762+ await stopProcess(appProcess);
763+ await restoreLaunchdService(launchdSnapshot);
764+ await new Promise<void>((resolve) => server.close(() => resolve()));
765+ rmSync(home, { recursive: true, force: true });
766+ }
767+};
768+
769+const runSupervisedPortSetting = async (runDir: string) => {
770+ const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-"));
771+ const dataDir = join(home, ".executor");
772+ const launchdSnapshot = captureLaunchdService();
773+ const oldPort = await freePort();
774+ const newPort = await freePort();
775+ let daemon: ChildProcess | undefined;
776+ let app: PackagedApp | undefined;
777+
778+ try {
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+ );
790+ daemon = started.child;
791+ expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
792+ await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 });
793+
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"));
800+
801+ const before = await page.evaluate<{ readonly origin: string } | null>(
802+ "window.executor.getServerConnection()",
803+ );
804+ expect(new URL(before!.origin).port, "test starts attached to the original port").toBe(
805+ String(oldPort),
806+ );
807+
808+ await page.evaluate(`window.executor.updateSettings({ port: ${JSON.stringify(newPort)} })`);
809+
810+ await page
811+ .evaluate("window.executor.restartServer().catch(() => undefined)")
812+ .catch(() => undefined);
813+ page = await reconnectPackagedPage(app);
814+ await page.waitForText("Settings", 120_000);
815+
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+ );
822+
823+ expect(after.settings.port, "the setting was persisted").toBe(newPort);
824+ expect(
825+ new URL(after.connection!.origin).port,
826+ "after restart, the active supervised daemon should be serving on the saved port",
827+ ).toBe(String(newPort));
828+ await page.screenshot(join(runDir, "02-restarted-on-new-port.png"));
829+ } finally {
830+ await closePackaged(app);
831+ await stopProcess(daemon);
832+ await restoreLaunchdService(launchdSnapshot);
833+ rmSync(home, { recursive: true, force: true });
834+ }
835+};
836+
837+const runSupervisedIntegrationsLoad = async (runDir: string) => {
838+ const home = mkdtempSync(join(tmpdir(), "executor-pkg-integrations-load-"));
839+ const dataDir = join(home, ".executor");
840+ const launchdSnapshot = captureLaunchdService();
841+ const port = await freePort();
842+ let daemon: ChildProcess | undefined;
843+ let app: PackagedApp | undefined;
844+
845+ try {
846+ writeStaleActiveServerProfile({ home, port });
847+ const started = await startSupervisedDaemon(
848+ {
849+ ...process.env,
850+ HOME: home,
851+ EXECUTOR_SUPERVISED: "1",
852+ EXECUTOR_DATA_DIR: dataDir,
853+ EXECUTOR_AUTH_TOKEN: "integrations-load-token",
854+ EXECUTOR_CLIENT: "desktop",
855+ },
856+ port,
857+ "localhost",
858+ );
859+ daemon = started.child;
860+ expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
861+ await waitForHttp(`http://localhost:${port}/`, { timeoutMs: 30_000 });
862+
863+ const rootDocument = await fetch(`http://localhost:${port}/`);
864+ expect(
865+ rootDocument.headers.get("cache-control"),
866+ "SPA boot document should not be cached",
867+ ).toBe("no-store");
868+ await rootDocument.body?.cancel();
869+ const indexDocument = await fetch(`http://localhost:${port}/index.html`);
870+ expect(
871+ indexDocument.headers.get("cache-control"),
872+ "direct index.html requests should not cache the SPA boot document",
873+ ).toBe("no-store");
874+ await indexDocument.body?.cancel();
875+
876+ app = await launchPackaged(home);
877+ const page = app.cdp;
878+
879+ const serverLabel = await waitForServerConnectionLabel(page, "Local Executor", 120_000);
880+ expect(serverLabel, "desktop must not auto-select a stale persisted server profile").toContain(
881+ "Local Executor",
882+ );
883+ await page.waitForExpression(
884+ `document.querySelector('a[href$="/integrations/executor"]') !== null`,
885+ 120_000,
886+ "the built-in Executor integration link",
887+ );
888+ const bootstrap = await page.evaluate<{
889+ readonly href: string;
890+ readonly navigationName: string;
891+ }>(
892+ `(() => {
893+ const navigation = performance.getEntriesByType("navigation")[0];
894+ return {
895+ href: location.href,
896+ navigationName: navigation?.name ?? "",
897+ };
898+ })()`,
899+ );
900+ expect(
901+ bootstrap.navigationName,
902+ "desktop should cache-bust each packaged renderer document load",
903+ ).toContain("_executor_desktop_launch=");
904+ expect(
905+ bootstrap.navigationName,
906+ "desktop should pass the daemon token during bootstrap",
907+ ).toContain("_token=");
908+ expect(
909+ bootstrap.href,
910+ "desktop should strip bootstrap cache-bust params after load",
911+ ).not.toContain("_executor_desktop_launch=");
912+ expect(bootstrap.href, "desktop should strip bootstrap token params after load").not.toContain(
913+ "_token=",
914+ );
915+ await page.screenshot(join(runDir, "01-integrations-loaded.png"));
916+ expect(
917+ await page.textPresent("Failed to load integrations").then((present) => (present ? 1 : 0)),
918+ "integrations should render from the attached daemon, not a cached 401/500 failure",
919+ ).toBe(0);
920+
921+ const connection = await page.evaluate<{ readonly origin: string } | null>(
922+ "window.executor.getServerConnection()",
923+ );
924+ expect(
925+ new URL(connection!.origin).port,
926+ "the packaged app is rendering data from the supervised daemon",
927+ ).toBe(String(port));
928+ } finally {
929+ await closePackaged(app);
930+ await stopProcess(daemon);
931+ await restoreLaunchdService(launchdSnapshot);
932+ rmSync(home, { recursive: true, force: true });
933+ }
934+};
e2e/local/auth.test.ts+10−8
@@ -1,16 +1,18 @@
11 // Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way
2−// a human tests it: run the dev CLI in a real terminal, watch `executor web`
3−// print its one-time `?_token=` URL, then drive a browser against it. Two clean
4−// stories, each its own film (terminal.cast + session.mp4 spliced by
5−// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`):
2+// a human tests it: run the dev CLI in a real terminal, watch
3+// `executor web --foreground` print its one-time `?_token=` URL, then drive a
4+// browser against it. Two clean stories, each its own film (terminal.cast +
5+// session.mp4 spliced by scenario.ts), each booting its OWN temporary server
6+// (own data dir, `--port 0`):
67 //
78 // 1. The CLI's ?_token URL boots straight into an authenticated console.
89 // 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the
910 // token connects.
1011 //
11−// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal
12−// and hands the printed URL to a body; the terminal stays up until the body is
13−// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes.
12+// `withLocalServer` (shared helper) runs `executor web --foreground` in a
13+// recorded terminal and hands the printed URL to a body; the terminal stays up
14+// until the body is done, then Ctrl-C shuts it (and its vite child) down so the
15+// PTY closes.
1416 import { expect } from "@effect/vitest";
1517 import { Effect } from "effect";
1618
@@ -30,7 +32,7 @@ scenario( · 13 unmodified lines
3032
3133 yield* withLocalServer(cli, runDir, ({ url, token }) =>
3234 browser.session(identity, async ({ page, step }) => {
33− await step("Open the ?_token URL printed by executor web", async () => {
35+ await step("Open the ?_token URL printed by executor web --foreground", async () => {
3436 await page.goto(url, { waitUntil: "domcontentloaded" });
3537 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
3638 // Integrations actually LOAD (the built-in Executor source) — proves
e2e/local/cli-open-stale-manifest.test.ts+93−0
@@ -0,0 +1,93 @@
1+// Local CLI: `executor open` must not trust a live pid in server.json until it
2+// has proven the recorded endpoint is actually the running Executor server.
3+import { execFile } from "node:child_process";
4+import { randomBytes } from "node:crypto";
5+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+import net from "node:net";
7+import { tmpdir } from "node:os";
8+import { delimiter, join } from "node:path";
9+import { fileURLToPath } from "node:url";
10+import { promisify } from "node:util";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import {
15+ normalizeExecutorServerConnection,
16+ serializeExecutorLocalServerManifest,
17+} from "@executor-js/sdk/shared";
18+
19+import { scenario } from "../src/scenario";
20+
21+const execFileAsync = promisify(execFile);
22+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
23+
24+const freePort = (): Promise<number> =>
25+ new Promise((resolve, reject) => {
26+ const srv = net.createServer();
27+ srv.on("error", reject);
28+ srv.listen(0, "127.0.0.1", () => {
29+ const port = (srv.address() as net.AddressInfo).port;
30+ srv.close(() => resolve(port));
31+ });
32+ });
33+
34+const openerName = (): string =>
35+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
36+
37+scenario(
38+ "CLI open · a stale manifest cannot print or open the saved bearer URL",
39+ { timeout: 120_000 },
40+ Effect.promise(async () => {
41+ const dataDir = mkdtempSync(join(tmpdir(), "executor-open-stale-"));
42+ const openerDir = mkdtempSync(join(tmpdir(), "executor-open-shim-"));
43+ try {
44+ const port = await freePort();
45+ const token = `stale-token-${randomBytes(4).toString("hex")}`;
46+ mkdirSync(join(dataDir, "server-control"), { recursive: true });
47+ writeFileSync(
48+ join(dataDir, "server-control", "server.json"),
49+ serializeExecutorLocalServerManifest({
50+ version: 1,
51+ kind: "foreground",
52+ pid: process.pid,
53+ startedAt: new Date().toISOString(),
54+ dataDir,
55+ scopeDir: dataDir,
56+ connection: normalizeExecutorServerConnection({
57+ origin: `http://127.0.0.1:${port}`,
58+ displayName: "Stale test server",
59+ auth: { kind: "bearer", token },
60+ }),
61+ owner: { client: "cli", version: null, executablePath: null },
62+ }),
63+ { mode: 0o600 },
64+ );
65+
66+ const openerPath = join(openerDir, openerName());
67+ writeFileSync(openerPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
68+ chmodSync(openerPath, 0o755);
69+
70+ const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "open"], {
71+ cwd: repoRoot,
72+ env: {
73+ ...process.env,
74+ EXECUTOR_DATA_DIR: dataDir,
75+ PATH: `${openerDir}${delimiter}${process.env.PATH ?? ""}`,
76+ },
77+ });
78+ const output = `${stdout}\n${stderr}`;
79+ expect(output, "stale endpoint should be rejected before printing the token URL").toContain(
80+ "Executor is not running.",
81+ );
82+ expect(output, "the recovery path should point users at durable setup").toContain("install");
83+ expect(output, "the old foreground behavior should remain discoverable").toContain(
84+ "web --foreground",
85+ );
86+ expect(output, "the stale bearer must not be printed").not.toContain(token);
87+ expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`);
88+ } finally {
89+ rmSync(openerDir, { recursive: true, force: true });
90+ rmSync(dataDir, { recursive: true, force: true });
91+ }
92+ }),
93+);
e2e/local/cli-systemd-unit-escaping.test.ts+44−0
@@ -0,0 +1,44 @@
1+// Artifact-level e2e for the Linux service install surface: the emitted
2+// systemd unit must not contain raw, unescaped paths or environment values
3+// whose spaces/quotes change systemd tokenization at boot.
4+import { expect } from "@effect/vitest";
5+import { Effect } from "effect";
6+
7+import { generateSystemdUnit } from "../../apps/cli/src/service";
8+import { scenario } from "../src/scenario";
9+
10+scenario(
11+ "CLI service · generated systemd units escape paths and environment values",
12+ {},
13+ Effect.sync(() => {
14+ const unit = generateSystemdUnit({
15+ execStart: [
16+ '/home/alice/Executor "Beta"/executor',
17+ "daemon",
18+ "run",
19+ "--foreground",
20+ "--port",
21+ "4789",
22+ ],
23+ environment: {
24+ EXECUTOR_SUPERVISED: "1",
25+ EXECUTOR_DATA_DIR: "/home/alice/Executor data",
26+ PATH: '/home/alice/bin:/opt/Bad "Dir"/bin',
27+ },
28+ workingDirectory: "/home/alice/Executor data",
29+ stdoutPath: "/home/alice/Executor data/logs/daemon.log",
30+ stderrPath: "/home/alice/Executor data/logs/daemon.error.log",
31+ });
32+
33+ const unsafeFragments = [
34+ 'ExecStart="/home/alice/Executor "Beta"/executor"',
35+ "Environment=EXECUTOR_DATA_DIR=/home/alice/Executor data",
36+ 'Environment=PATH=/home/alice/bin:/opt/Bad "Dir"/bin',
37+ "WorkingDirectory=/home/alice/Executor data",
38+ "StandardOutput=append:/home/alice/Executor data/logs/daemon.log",
39+ "StandardError=append:/home/alice/Executor data/logs/daemon.error.log",
40+ ].filter((fragment) => unit.includes(fragment));
41+
42+ expect(unsafeFragments, `unsafe raw fragments in unit:\n${unit}`).toEqual([]);
43+ }),
44+);
e2e/local/cli-web-transition.test.ts+57−0
@@ -0,0 +1,57 @@
1+// Local CLI: `executor web` used to mean "start a foreground server". The
2+// first-time CLI setup path is now explicit: install the durable background
3+// service first, then use `executor web` to open it. A fresh `web` invocation
4+// should guide the user without minting local-server credentials/manifest state
5+// or binding ports.
6+import { execFile } from "node:child_process";
7+import { existsSync, mkdtempSync, rmSync } from "node:fs";
8+import { tmpdir } from "node:os";
9+import { join } from "node:path";
10+import { fileURLToPath } from "node:url";
11+import { promisify } from "node:util";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+
16+import { scenario } from "../src/scenario";
17+
18+const execFileAsync = promisify(execFile);
19+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
20+
21+scenario(
22+ "CLI web · a fresh install points users at setup instead of starting a foreground server",
23+ { timeout: 120_000 },
24+ Effect.promise(async () => {
25+ const root = mkdtempSync(join(tmpdir(), "executor-web-transition-"));
26+ const dataDir = join(root, "data");
27+ try {
28+ const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "web"], {
29+ cwd: repoRoot,
30+ env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
31+ });
32+ const output = `${stdout}\n${stderr}`;
33+
34+ expect(output, "plain web should not start the old foreground server").not.toContain("Open:");
35+ expect(output, "plain web should explain that no service is running").toContain(
36+ "Executor is not running.",
37+ );
38+ expect(output, "plain web should direct first-time users to durable setup").toContain(
39+ "install",
40+ );
41+ expect(
42+ output,
43+ "plain web should keep the temporary-server escape hatch discoverable",
44+ ).toContain("web --foreground");
45+ expect(
46+ existsSync(join(dataDir, "server-control", "auth.json")),
47+ "plain web should not mint a local auth token",
48+ ).toBe(false);
49+ expect(
50+ existsSync(join(dataDir, "server-control", "server.json")),
51+ "plain web should not write a foreground server manifest",
52+ ).toBe(false);
53+ } finally {
54+ rmSync(root, { recursive: true, force: true });
55+ }
56+ }),
57+);
e2e/local/local-server.ts+15−12
@@ -1,9 +1,9 @@
1−// Shared helper for the `local` e2e project: boot a real `executor web` in a
2−// recorded terminal, parse its printed one-time `?_token=` URL, and run a body
3−// against it. Each scenario boots its OWN server (own throwaway data dir,
4−// `--port 0`) so files can run in parallel without colliding. The terminal
5−// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so
6−// the vite child dies and the PTY closes).
1+// Shared helper for the `local` e2e project: boot a real temporary server with
2+// `executor web --foreground` in a recorded terminal, parse its printed one-time
3+// `?_token=` URL, and run a body against it. Each scenario boots its OWN server
4+// (own throwaway data dir, `--port 0`) so files can run in parallel without
5+// colliding. The terminal stays up until the body settles, then Ctrl-C gives a
6+// graceful shutdown (so the vite child dies and the PTY closes).
77 import { mkdtempSync, rmSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
@@ -29,10 +29,11 @@ export interface ServerHandle { · 19 unmodified lines
2929 }
3030
3131 /**
32− * Boot `executor web` and run `body` against the resulting {@link ServerHandle}.
33− * Keeps the server up until the body settles, then Ctrl-C for a graceful
34− * shutdown. Cleans up the throwaway data dir. The body may drive the browser,
35− * a typed API client, an MCP client — anything that needs the live server.
32+ * Boot `executor web --foreground` and run `body` against the resulting
33+ * {@link ServerHandle}. Keeps the server up until the body settles, then Ctrl-C
34+ * for a graceful shutdown. Cleans up the throwaway data dir. The body may drive
35+ * the browser, a typed API client, an MCP client — anything that needs the live
36+ * server.
3637 */
3738 export const withLocalServer = (
3839 cli: CliSurface,
@@ -54,7 +55,7 @@ export const withLocalServer = ( · 15 unmodified lines
5455 yield* Effect.all(
5556 [
5657 cli.session(
57− ["bun", "run", "dev:cli", "web", "--port", "0"],
58+ ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"],
5859 async (term) => {
5960 markRecordingStart(runDir, "terminal");
6061 markFocus(runDir, "terminal");
@@ -64,7 +65,9 @@ export const withLocalServer = ( · 3 unmodified lines
6465 );
6566 const url = TOKEN_URL.exec(snapshot.text)?.[0];
6667 if (!url) {
67− throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`);
68+ throw new Error(
69+ `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`,
70+ );
6871 }
6972 publishUrl(url);
7073 await bodyDone;
e2e/local/mcp-browser-approve.test.ts+6−5
@@ -5,11 +5,12 @@ · 4 unmodified lines
55 // the MCP `resume` tool (auth on the API path), so it never drives the browser
66 // page and could not catch this. This drives the real page in a real browser.
77 //
8−// Flow: boot `executor web` → create a require_approval policy on a built-in
9−// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser
10−// → the server returns a paused `approvalUrl` → open it in the browser (with the
11−// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a
12−// negative: the approval endpoint 401s without the bearer.
8+// Flow: boot `executor web --foreground` → create a require_approval policy on a
9+// built-in tool → an MCP client (bearer) executes that tool with
10+// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it
11+// in the browser (with the `?_token` bootstrap) → click Approve → the MCP
12+// `resume` call completes. Plus a negative: the approval endpoint 401s without
13+// the bearer.
1314 import { expect } from "@effect/vitest";
1415 import { Effect } from "effect";
1516 import { HttpApiClient } from "effect/unstable/httpapi";
e2e/local/vite-dev-routing.test.ts+228−0
@@ -0,0 +1,228 @@
1+// Local-only: plain `apps/local` Vite dev must route the same local-only HTTP
2+// surfaces as production `executor web`. These routes live outside the typed
3+// `/api` HttpApi: `/api/health`, `/api/oauth/await/*`, and browser approval's
4+// `/api/mcp-sessions/*`.
5+import { type ChildProcess, spawn } from "node:child_process";
6+import { randomBytes } from "node:crypto";
7+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8+import net from "node:net";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
11+import { fileURLToPath } from "node:url";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
18+import { scenario } from "../src/scenario";
19+import { waitForHttp } from "../setup/boot";
20+
21+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22+const localAppDir = join(repoRoot, "apps/local");
23+
24+const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
25+const EXECUTE_CODE = `
26+const result = await tools.executor.coreTools.policies.list({});
27+return JSON.stringify(result);
28+`;
29+
30+const freePort = (): Promise<number> =>
31+ new Promise((resolve, reject) => {
32+ const srv = net.createServer();
33+ srv.on("error", reject);
34+ srv.listen(0, "127.0.0.1", () => {
35+ const port = (srv.address() as net.AddressInfo).port;
36+ srv.close(() => resolve(port));
37+ });
38+ });
39+
40+const readToken = async (dataDir: string): Promise<string> => {
41+ const path = join(dataDir, "server-control", "auth.json");
42+ const deadline = Date.now() + 30_000;
43+ while (Date.now() < deadline) {
44+ if (existsSync(path)) {
45+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown };
46+ if (typeof parsed.token === "string") return parsed.token;
47+ }
48+ await new Promise((resolve) => setTimeout(resolve, 100));
49+ }
50+ throw new Error(`Vite dev did not mint ${path}`);
51+};
52+
53+const stopTree = async (child: ChildProcess): Promise<void> => {
54+ if (child.pid === undefined || child.exitCode !== null) return;
55+ try {
56+ process.kill(-child.pid, "SIGTERM");
57+ } catch {
58+ child.kill("SIGTERM");
59+ }
60+ await Promise.race([
61+ new Promise((resolve) => child.once("exit", resolve)),
62+ new Promise((resolve) => setTimeout(resolve, 5_000)),
63+ ]);
64+ if (child.exitCode === null) {
65+ try {
66+ process.kill(-child.pid, "SIGKILL");
67+ } catch {
68+ child.kill("SIGKILL");
69+ }
70+ }
71+};
72+
73+interface ViteDev {
74+ readonly origin: string;
75+ readonly token: string;
76+ readonly stop: () => Promise<void>;
77+}
78+
79+const startPlainViteDev = async (): Promise<ViteDev> => {
80+ const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-"));
81+ const port = await freePort();
82+ const origin = `http://127.0.0.1:${port}`;
83+ let logs = "";
84+ const child = spawn(
85+ "bunx",
86+ ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
87+ {
88+ cwd: localAppDir,
89+ env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) },
90+ stdio: ["ignore", "pipe", "pipe"],
91+ detached: true,
92+ },
93+ );
94+ child.stdout?.on("data", (chunk: Buffer) => {
95+ logs += chunk.toString();
96+ });
97+ child.stderr?.on("data", (chunk: Buffer) => {
98+ logs += chunk.toString();
99+ });
100+
101+ try {
102+ await waitForHttp(origin, { timeoutMs: 90_000 });
103+ const token = await readToken(dataDir);
104+ return {
105+ origin,
106+ token,
107+ stop: async () => {
108+ await stopTree(child);
109+ rmSync(dataDir, { recursive: true, force: true });
110+ },
111+ };
112+ } catch (error) {
113+ await stopTree(child);
114+ rmSync(dataDir, { recursive: true, force: true });
115+ throw new Error(`plain Vite dev failed to boot:\n${logs}\n${String(error)}`);
116+ }
117+};
118+
119+scenario(
120+ "Local Vite dev · local-only API routes match production routing",
121+ { timeout: 180_000 },
122+ Effect.gen(function* () {
123+ const vite = yield* Effect.promise(() => startPlainViteDev());
124+ yield* Effect.promise(async () => {
125+ const failures: string[] = [];
126+ let policyId: string | null = null;
127+ const auth = { authorization: `Bearer ${vite.token}` };
128+
129+ try {
130+ const health = await fetch(`${vite.origin}/api/health`);
131+ const healthText = await health.text();
132+ if (health.status !== 200 || healthText !== "ok") {
133+ failures.push(`/api/health returned ${health.status} ${JSON.stringify(healthText)}`);
134+ }
135+
136+ const awaited = await fetch(`${vite.origin}/api/oauth/await/session-1`, {
137+ headers: auth,
138+ });
139+ const awaitedText = await awaited.text();
140+ if (awaited.status !== 200 || awaitedText !== "null") {
141+ failures.push(
142+ `/api/oauth/await/session-1 returned ${awaited.status} ${JSON.stringify(awaitedText)}`,
143+ );
144+ }
145+
146+ const created = await fetch(`${vite.origin}/api/policies`, {
147+ method: "POST",
148+ headers: { ...auth, "content-type": "application/json" },
149+ body: JSON.stringify({
150+ owner: "org",
151+ pattern: APPROVAL_TARGET_TOOL,
152+ action: "require_approval",
153+ }),
154+ });
155+ if (!created.ok) {
156+ failures.push(`/api/policies setup returned ${created.status} ${await created.text()}`);
157+ } else {
158+ const policy = (await created.json()) as { readonly id?: string };
159+ policyId = typeof policy.id === "string" ? policy.id : null;
160+
161+ const mcp = new Client(
162+ { name: `vite-routing-${randomBytes(3).toString("hex")}`, version: "1.0.0" },
163+ { capabilities: {} },
164+ );
165+ const transport = new StreamableHTTPClientTransport(
166+ new URL(`${vite.origin}/mcp?elicitation_mode=browser`),
167+ { requestInit: { headers: auth } },
168+ );
169+ await mcp.connect(transport);
170+ try {
171+ const executed = await mcp.callTool({
172+ name: "execute",
173+ arguments: { code: EXECUTE_CODE },
174+ });
175+ const paused = executed.structuredContent as {
176+ readonly status?: string;
177+ readonly executionId?: string;
178+ readonly approvalUrl?: string;
179+ };
180+ if (
181+ paused.status !== "user_approval_required" ||
182+ typeof paused.executionId !== "string" ||
183+ typeof paused.approvalUrl !== "string"
184+ ) {
185+ failures.push(
186+ `MCP setup did not produce a browser approval: ${JSON.stringify(paused)}`,
187+ );
188+ } else {
189+ const approvalUrl = new URL(paused.approvalUrl);
190+ const sessionId = approvalUrl.searchParams.get("mcp_session_id");
191+ if (!sessionId) {
192+ failures.push(`approval URL had no mcp_session_id: ${paused.approvalUrl}`);
193+ } else {
194+ const detail = await fetch(
195+ `${vite.origin}/api/mcp-sessions/${encodeURIComponent(
196+ sessionId,
197+ )}/executions/${encodeURIComponent(paused.executionId)}`,
198+ { headers: auth },
199+ );
200+ if (detail.status !== 200) {
201+ failures.push(
202+ `/api/mcp-sessions paused-detail returned ${detail.status} ${await detail.text()}`,
203+ );
204+ }
205+ }
206+ }
207+ } finally {
208+ await mcp.close();
209+ }
210+ }
211+ } finally {
212+ if (policyId) {
213+ await fetch(`${vite.origin}/api/policies/${encodeURIComponent(policyId)}`, {
214+ method: "DELETE",
215+ headers: { ...auth, "content-type": "application/json" },
216+ body: JSON.stringify({ owner: "org" }),
217+ }).catch(() => {});
218+ }
219+ await vite.stop();
220+ }
221+
222+ expect(
223+ failures,
224+ "plain apps/local Vite dev should special-case the same local-only routes as production",
225+ ).toEqual([]);
226+ });
227+ }),
228+);
e2e/selfhost/mcp-browser-approval-ownership.test.ts+138−0
@@ -0,0 +1,138 @@
1+// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A
2+// signed-in user who does not own the MCP session must not be able to read the
3+// paused execution or record the human decision for it.
4+import { randomBytes } from "node:crypto";
5+
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+import { composePluginApi } from "@executor-js/api/server";
9+
10+import { scenario } from "../src/scenario";
11+import { Api, Mcp, Target } from "../src/services";
12+import { parseBrowserApproval } from "../src/surfaces/mcp";
13+import type { Identity } from "../src/target";
14+import { signInSession } from "../targets/selfhost";
15+
16+const coreApi = composePluginApi([] as const);
17+
18+const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
19+const EXECUTE_CODE = `
20+const result = await tools.executor.coreTools.policies.list({});
21+return JSON.stringify(result);
22+`;
23+
24+const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
25+ const cookie = admin.headers?.cookie;
26+ expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
27+
28+ const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
29+ method: "POST",
30+ headers: {
31+ "content-type": "application/json",
32+ cookie: cookie!,
33+ origin: new URL(baseUrl).origin,
34+ },
35+ body: JSON.stringify({ role: "member" }),
36+ });
37+ expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
38+ const inviteBody = (await invite.json()) as { readonly code?: string };
39+ expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
40+
41+ const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`;
42+ const password = "approval-cross-user-password-123";
43+ const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
44+ method: "POST",
45+ headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
46+ body: JSON.stringify({
47+ email,
48+ password,
49+ name: email,
50+ inviteCode: inviteBody.code,
51+ }),
52+ });
53+ expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
54+
55+ const session = await signInSession(baseUrl, { email, password });
56+ return {
57+ label: email,
58+ credentials: { email, password },
59+ headers: { cookie: session.cookieHeader },
60+ cookies: session.cookies,
61+ };
62+};
63+
64+const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL =>
65+ new URL(
66+ `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(
67+ executionId,
68+ )}`,
69+ baseUrl,
70+ );
71+
72+scenario(
73+ "MCP browser approval · another self-host user cannot act on someone else's paused session",
74+ { timeout: 180_000 },
75+ Effect.gen(function* () {
76+ const target = yield* Target;
77+ const api = yield* Api;
78+ const mcp = yield* Mcp;
79+ const owner = yield* target.newIdentity();
80+ const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner));
81+ const client = yield* api.client(coreApi, owner);
82+
83+ const policy = yield* client.policies.create({
84+ payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
85+ });
86+
87+ yield* Effect.gen(function* () {
88+ const session = mcp.session(owner, { elicitationMode: "browser" });
89+ yield* session.listTools();
90+
91+ const paused = yield* session.call("execute", { code: EXECUTE_CODE });
92+ const approval = parseBrowserApproval(paused);
93+ const approvalUrl = new URL(approval.approvalUrl);
94+ const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
95+ expect(typeof mcpSessionId, "approval URL is tied to the MCP session").toBe("string");
96+
97+ const otherHeaders = {
98+ cookie: other.headers!.cookie,
99+ origin: new URL(target.baseUrl).origin,
100+ };
101+ const detail = yield* Effect.promise(() =>
102+ fetch(approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId), {
103+ headers: otherHeaders,
104+ }),
105+ );
106+ expect([403, 404], "a different signed-in user cannot read the paused execution").toContain(
107+ detail.status,
108+ );
109+
110+ const decision = yield* Effect.promise(() =>
111+ fetch(
112+ new URL(
113+ `${approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId).pathname}/resume`,
114+ target.baseUrl,
115+ ),
116+ {
117+ method: "POST",
118+ headers: {
119+ ...otherHeaders,
120+ "content-type": "application/json",
121+ },
122+ body: JSON.stringify({ action: "accept" }),
123+ },
124+ ),
125+ );
126+ expect(
127+ [403, 404],
128+ "a different signed-in user cannot approve the paused execution",
129+ ).toContain(decision.status);
130+ }).pipe(
131+ Effect.ensuring(
132+ client.policies
133+ .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
134+ .pipe(Effect.ignore),
135+ ),
136+ );
137+ }),
138+);
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/hosts/mcp/src/in-memory-session-store.ts+34−8
@@ -82,13 +82,19 @@ export interface InMemoryMcpSessionStore { · 81 unmodified lines
8282 * paused-execution detail the console approval page renders. Returns the
8383 * paused `{ text, structured }` or a 404. Null if the path does not match.
8484 */
85− readonly handlePausedRequest: (request: Request) => Promise<Response | null>;
85+ readonly handlePausedRequest: (
86+ request: Request,
87+ principal?: Principal,
88+ ) => Promise<Response | null>;
8689 /**
8790 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —
8891 * record the human's decision and wake the long-polling `resume` tool call.
8992 * Null if the path does not match.
9093 */
91− readonly handleApprovalRequest: (request: Request) => Promise<Response | null>;
94+ readonly handleApprovalRequest: (
95+ request: Request,
96+ principal?: Principal,
97+ ) => Promise<Response | null>;
9298 /** Dispose every live session — wire into the host's shutdown (not a seam). */
9399 readonly close: () => Promise<void>;
94100 }
@@ -259,6 +265,16 @@ export const makeInMemoryMcpSessionStore = ( · 164 unmodified lines
259265 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
260266 };
261267
268+ const ownerAccess = (
269+ sessionId: string,
270+ principal: Principal | undefined,
271+ ): "allowed" | "not-found" | "forbidden" => {
272+ const owner = owners.get(sessionId);
273+ if (!owner) return "not-found";
274+ if (principal && !principalOwns(owner, principal)) return "forbidden";
275+ return "allowed";
276+ };
277+
262278 /** Resolve a paused execution from the session that owns it, for HTTP approval. */
263279 const pausedFromSession = (
264280 sessionId: string,
@@ -274,25 +290,35 @@ export const makeInMemoryMcpSessionStore = ( · 9 unmodified lines
274290 );
275291 };
276292
277− const handlePausedRequest = async (request: Request): Promise<Response | null> => {
293+ const handlePausedRequest = async (
294+ request: Request,
295+ principal?: Principal,
296+ ): Promise<Response | null> => {
278297 const match = PAUSED_PATH.exec(new URL(request.url).pathname);
279298 if (!match) return null;
280299 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
281− const paused = await pausedFromSession(
282− decodeURIComponent(match[1]!),
283− decodeURIComponent(match[2]!),
284− );
300+ const sessionId = decodeURIComponent(match[1]!);
301+ const access = ownerAccess(sessionId, principal);
302+ if (access === "forbidden") return json({ error: "Forbidden" }, 403);
303+ if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
304+ const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!));
285305 if (!paused) return json({ error: "Paused execution not found" }, 404);
286306 return json({ text: paused.text, structured: paused.structured });
287307 };
288308
289− const handleApprovalRequest = async (request: Request): Promise<Response | null> => {
309+ const handleApprovalRequest = async (
310+ request: Request,
311+ principal?: Principal,
312+ ): Promise<Response | null> => {
290313 const match = RESUME_PATH.exec(new URL(request.url).pathname);
291314 if (!match) return null;
292315 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
293316
294317 const sessionId = decodeURIComponent(match[1]!);
295318 const executionId = decodeURIComponent(match[2]!);
319+ const access = ownerAccess(sessionId, principal);
320+ if (access === "forbidden") return json({ error: "Forbidden" }, 403);
321+ if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
296322 // The session must still hold the paused execution — guards stale ids and
297323 // confirms the execution belongs to this session before recording.
298324 const paused = await pausedFromSession(sessionId, executionId);
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 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t