History

Fix local service convergence

Rhys Sullivan committed 4 months ago

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

Unified · Split

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