History

Fix supervised/local regressions (#1020)

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

Rhys Sullivan committed 5 months ago

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

Unified · Split

45 files
  1. .github/workflows/ci.yml +1 −1
  2. .github/workflows/publish-desktop.yml +6 −6
  3. README.md +6 −3
  4. apps/cli/package.json +1 −0
  5. apps/cli/src/daemon.test.ts +131 −0
  6. apps/cli/src/daemon.ts +51 −2
  7. apps/cli/src/main.ts +283 −96
  8. apps/cli/src/service.test.ts +17 −4
  9. apps/cli/src/service.ts +60 −23
  10. apps/desktop/build/entitlements.mac.plist +2 −2
  11. apps/desktop/electron-builder.config.ts +7 −12
  12. apps/desktop/electron-builder.e2e.config.ts +1 −1
  13. apps/desktop/package.json +1 −1
  14. apps/desktop/scripts/build-sidecar.ts +38 −244
  15. apps/desktop/scripts/smoke-sidecar.ts +25 −16
  16. apps/desktop/src/main/index.ts +96 −21
  17. apps/desktop/src/main/local-auth.ts +48 −0
  18. apps/desktop/src/main/service.ts +92 −182
  19. apps/desktop/src/main/settings.ts +4 −1
  20. apps/desktop/src/main/sidecar.ts +105 −64
  21. apps/desktop/src/shared/server-settings.ts +1 −1
  22. apps/desktop/src/sidecar/native-bindings.ts +4 −3
  23. apps/desktop/src/sidecar/server.ts +9 −9
  24. apps/host-selfhost/src/mcp/index.ts +35 −6
  25. apps/local/src/serve.ts +17 −8
  26. apps/local/vite.config.ts +31 −13
  27. bun.lock +1 −0
  28. e2e/cli/service-install-takeover.test.ts +213 −0
  29. e2e/desktop-packaged/supervised-attach.test.ts +273 −72
  30. e2e/desktop-packaged/supervised-regressions.test.ts +934 −0
  31. e2e/local/auth.test.ts +10 −8
  32. e2e/local/cli-open-stale-manifest.test.ts +93 −0
  33. e2e/local/cli-systemd-unit-escaping.test.ts +44 −0
  34. e2e/local/cli-web-transition.test.ts +57 −0
  35. e2e/local/local-server.ts +15 −12
  36. e2e/local/mcp-browser-approve.test.ts +6 −5
  37. e2e/local/vite-dev-routing.test.ts +228 −0
  38. e2e/selfhost/mcp-browser-approval-ownership.test.ts +138 −0
  39. e2e/setup/desktop-packaged.globalsetup.ts +15 −18
  40. e2e/vitest.config.ts +2 −2
  41. packages/app/src/web/server-connection-menu.tsx +75 −10
  42. packages/hosts/mcp/src/in-memory-session-store.ts +34 −8
  43. packages/plugins/desktop-settings/src/client.tsx +182 −168
  44. packages/react/src/api/local-auth.tsx +20 −2
  45. packages/react/src/api/server-connection.tsx +7 −3
.github/workflows/ci.yml+1−1
@@ -89,7 +89,7 @@ jobs: · 88 unmodified lines
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
README.md+6−3
@@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e · 9 unmodified lines
10 10
11```bash11```bash
12npm install -g executor12npm install -g executor
13executor install
13executor web14executor web
14```15```
15 16
16This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools.17This installs the local background service and opens the web UI. From there, add your first source and start using tools.
17 18
18### Use as an MCP server19### Use as an MCP server
19 20
@@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h · 40 unmodified lines
60 61
61### Via the web UI62### Via the web UI
62 63
63Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.64Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
64 65
65### Via the CLI66### Via the CLI
66 67
@@ -119,7 +120,9 @@ executor resume --execution-id exec_123 · 52 unmodified lines
119## CLI reference120## CLI reference
120 121
121```bash122```bash
122executor web # start runtime + web UI123executor install # install/start the durable background service
124executor web # open the running web UI
125executor web --foreground # start a temporary foreground runtime + web UI
123executor daemon run # start persistent local daemon in background126executor daemon run # start persistent local daemon in background
124executor daemon status # show daemon status127executor daemon status # show daemon status
125executor daemon stop # stop daemon128executor daemon stop # stop daemon
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+51−2
@@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( · 88 unmodified lines
89 // misconfigured base URL can't leak the bearer token to a third-party host.89 // misconfigured base URL can't leak the bearer token to a third-party host.
90 const url = new URL("/api/health", input.baseUrl);90 const url = new URL("/api/health", input.baseUrl);
91 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });91 const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
92 await response.body?.cancel();92 const body = await response.text();
93 return response.ok;93 return response.ok && body.trim() === "ok";
94 }).pipe(Effect.catchCause(() => Effect.succeed(false)));94 }).pipe(Effect.catchCause(() => Effect.succeed(false)));
95 95
96// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
@@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: { · 185 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+283−96
@@ -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,44 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< · 19 unmodified lines
286 );305 );
287 });306 });
288 307
308const takeOverActiveLocalServer = (input?: {
309 readonly onlyKind?: ExecutorLocalServerKind;
310}): Effect.Effect<
311 ExecutorLocalServerManifest | null,
312 Error,
313 FileSystem.FileSystem | PlatformPath.Path
314> =>
315 Effect.gen(function* () {
316 const manifest = yield* readLocalServerManifest();
317 if (!manifest) return null;
318 if (input?.onlyKind && manifest.kind !== input.onlyKind) return null;
319
320 if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) {
321 yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
322 return null;
323 }
324
325 yield* terminatePid(manifest.pid).pipe(Effect.ignore);
326 const stopped = yield* waitForUnreachable({
327 check: isServerReachable(manifest.connection.origin),
328 timeoutMs: DAEMON_STOP_TIMEOUT_MS,
329 intervalMs: DAEMON_BOOT_POLL_MS,
330 });
331 if (!stopped) {
332 return yield* Effect.fail(
333 new Error(
334 [
335 `The existing Executor ${manifest.kind} at ${manifest.connection.origin} (pid ${manifest.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`,
336 "Stop it manually and re-run.",
337 ].join("\n"),
338 ),
339 );
340 }
341
342 yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
343 return manifest;
344 });
345
289const publishLocalServerManifest = (input: {346const publishLocalServerManifest = (input: {
290 readonly kind: ExecutorLocalServerKind;347 readonly kind: ExecutorLocalServerKind;
291 readonly connection: ExecutorServerConnection;348 readonly connection: ExecutorServerConnection;
@@ -897,7 +954,7 @@ const runDaemonSession = (input: { · 605 unmodified lines
897 // and crash-loop under KeepAlive. (Found by a real reboot test with954 // and crash-loop under KeepAlive. (Found by a real reboot test with
898 // integration data in the DB.)955 // integration data in the DB.)
899 if (process.env.EXECUTOR_SUPERVISED) {956 if (process.env.EXECUTOR_SUPERVISED) {
900 yield* removeLocalServerManifest().pipe(Effect.ignore);957 yield* takeOverActiveLocalServer().pipe(Effect.ignore);
901 } else {958 } else {
902 yield* assertNoOtherActiveLocalServer();959 yield* assertNoOtherActiveLocalServer();
903 }960 }
@@ -907,15 +964,34 @@ const runDaemonSession = (input: { · 3 unmodified lines
907 if (existing) {964 if (existing) {
908 const existingUrl = daemonBaseUrl(existing.hostname, existing.port);965 const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
909 if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {966 if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {
910 return yield* Effect.fail(967 if (process.env.EXECUTOR_SUPERVISED) {
911 new Error(968 yield* terminatePid(existing.pid).pipe(Effect.ignore);
912 [969 const stopped = yield* waitForUnreachable({
913 `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,970 check: isServerReachable(existingUrl),
914 `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,971 timeoutMs: DAEMON_STOP_TIMEOUT_MS,
915 `Stop it first: ${cliPrefix} daemon stop`,972 intervalMs: DAEMON_BOOT_POLL_MS,
916 ].join("\n"),973 });
917 ),974 if (!stopped) {
918 );975 return yield* Effect.fail(
976 new Error(
977 [
978 `The existing daemon for scope ${scopeId} at ${existingUrl} (pid ${existing.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`,
979 "Stop it manually and re-run.",
980 ].join("\n"),
981 ),
982 );
983 }
984 } else {
985 return yield* Effect.fail(
986 new Error(
987 [
988 `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
989 `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
990 `Stop it first: ${cliPrefix} daemon stop`,
991 ].join("\n"),
992 ),
993 );
994 }
919 }995 }
920 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });996 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
921 }997 }
@@ -1916,28 +1992,45 @@ const serverCommand = Command.make("server").pipe( · 994 unmodified lines
1916const webCommand = Command.make(1992const webCommand = Command.make(
1917 "web",1993 "web",
1918 {1994 {
1919 port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)),1995 foreground: Options.boolean("foreground")
1996 .pipe(Options.withDefault(false))
1997 .pipe(
1998 Options.withDescription(
1999 "Run a temporary web server in this terminal. By default, web opens the installed background service.",
2000 ),
2001 ),
2002 port: Options.integer("port")
2003 .pipe(Options.withDefault(DEFAULT_PORT))
2004 .pipe(Options.withDescription("Port for the temporary --foreground server.")),
1920 hostname: Options.string("hostname")2005 hostname: Options.string("hostname")
1921 .pipe(Options.withDefault("127.0.0.1"))2006 .pipe(Options.withDefault("127.0.0.1"))
1922 .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")),2007 .pipe(
2008 Options.withDescription(
2009 "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.",
2010 ),
2011 ),
1923 allowedHost: Options.string("allowed-host")2012 allowedHost: Options.string("allowed-host")
1924 .pipe(Options.atLeast(0))2013 .pipe(Options.atLeast(0))
1925 .pipe(2014 .pipe(
1926 Options.withDescription(2015 Options.withDescription(
1927 "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",2016 "For --foreground, grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
1928 ),2017 ),
1929 ),2018 ),
1930 authToken: Options.string("auth-token")2019 authToken: Options.string("auth-token")
1931 .pipe(Options.optional)2020 .pipe(Options.optional)
1932 .pipe(2021 .pipe(
1933 Options.withDescription(2022 Options.withDescription(
1934 "Override the bearer token. Defaults to the stable token in auth.json.",2023 "For --foreground, override the bearer token. Defaults to the stable token in auth.json.",
1935 ),2024 ),
1936 ),2025 ),
1937 scope,2026 scope,
1938 },2027 },
1939 ({ port, scope, hostname, allowedHost, authToken }) =>2028 ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
1940 Effect.gen(function* () {2029 Effect.gen(function* () {
2030 if (!foreground) {
2031 yield* openRunningLocalWebApp();
2032 return;
2033 }
1941 applyScope(scope);2034 applyScope(scope);
1942 yield* runForegroundSession({2035 yield* runForegroundSession({
1943 port,2036 port,
@@ -1946,7 +2039,7 @@ const webCommand = Command.make( · 2 unmodified lines
1946 authToken: Option.getOrUndefined(authToken),2039 authToken: Option.getOrUndefined(authToken),
1947 });2040 });
1948 }),2041 }),
1949).pipe(Command.withDescription("Start a foreground web session"));2042).pipe(Command.withDescription("Open the Executor web UI"));
1950 2043
1951const daemonRunCommand = Command.make(2044const daemonRunCommand = Command.make(
1952 "run",2045 "run",
@@ -2108,80 +2201,132 @@ const mcpCommand = Command.make( · 155 unmodified lines
2108 2201
2109const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;2202const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2110 2203
2111const serviceInstallCommand = Command.make(2204const portFromOrigin = (origin: string): number | null => {
2112 "install",2205 try {
2113 {2206 const url = new URL(origin);
2114 port: Options.integer("port")2207 if (!url.port) return url.protocol === "https:" ? 443 : 80;
2115 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))2208 const port = Number.parseInt(url.port, 10);
2116 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),2209 return Number.isInteger(port) && port > 0 ? port : null;
2117 },2210 } catch {
2118 ({ port }) =>2211 return null;
2119 Effect.gen(function* () {2212 }
2120 if (isDevMode) {2213};
2121 return yield* Effect.fail(
2122 new Error(
2123 [
2124 "`service install` requires the compiled `executor` binary so the OS can run it directly.",
2125 `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2126 ].join("\n"),
2127 ),
2128 );
2129 }
2130 2214
2131 const backend = getServiceBackend();2215const servicePortOption = () =>
2132 if (!backend.automated) {2216 Options.integer("port")
2133 // Unsupported platforms surface their manual steps via the install error.2217 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2134 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2218 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
2135 return;2219
2136 }2220const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["platform"]): string => {
2221 switch (platform) {
2222 case "darwin":
2223 return "launchd";
2224 case "linux":
2225 return "systemd --user";
2226 case "win32":
2227 return "Windows Task Scheduler";
2228 case "unsupported":
2229 return "manual setup";
2230 }
2231};
2137 2232
2138 // Don't fight an already-running local server against the same data dir2233const installService = (port: number, commandName: string) =>
2139 // (a desktop sidecar, a foreground `executor web`, or an existing daemon).2234 Effect.gen(function* () {
2140 const active = yield* readActiveLocalServerManifest();2235 const command = `${cliPrefix} ${commandName}`;
2141 if (active) {2236 if (isDevMode) {
2142 const status = yield* backend.status();2237 return yield* Effect.fail(
2143 if (status.registered && status.running && active.kind === "cli-daemon") {2238 new Error(
2144 console.log(2239 [
2145 `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,2240 `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`,
2146 );2241 `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2147 return;2242 ].join("\n"),
2148 }2243 ),
2149 return yield* Effect.fail(2244 );
2150 new Error(2245 }
2151 [
2152 `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2153 `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2154 ].join("\n"),
2155 ),
2156 );
2157 }
2158 2246
2159 // The unit carries no secret: the supervised daemon mints/loads its bearer2247 const backend = getServiceBackend();
2160 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read2248 if (!backend.automated) {
2161 // the same file — so reachability is the credential-free /api/health probe.2249 // Unsupported platforms surface their manual steps via the install error.
2162 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2250 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2251 return;
2252 }
2163 2253
2164 const origin = supervisedServiceOrigin(port);2254 const status = yield* backend.status();
2165 const reachable = yield* waitForReachable({2255 const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2166 check: isServerReachable(origin),2256 const plan = planServiceInstall({
2167 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,2257 registered: status.registered,
2168 intervalMs: DAEMON_BOOT_POLL_MS,2258 running: status.running,
2169 });2259 activeKind: active?.kind ?? null,
2170 if (!reachable) {2260 activePid: active?.pid ?? null,
2171 return yield* Effect.fail(2261 servicePid: status.pid,
2172 new Error(2262 activeVersion: active?.owner.version ?? null,
2173 [2263 activeExecutablePath: active?.owner.executablePath ?? null,
2174 `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,2264 activePort: active ? portFromOrigin(active.connection.origin) : null,
2175 `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,2265 requestedPort: port,
2176 ].join("\n"),2266 currentVersion: CLI_VERSION,
2177 ),2267 currentExecutablePath: process.execPath,
2268 });
2269
2270 if (plan === "noop") {
2271 const where = active ? ` at ${active.connection.origin} (pid ${active.pid})` : "";
2272 console.log(`Executor background service is already running${where}.`);
2273 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2274 return;
2275 }
2276
2277 if (plan === "takeover-then-install") {
2278 const replaced = yield* takeOverActiveLocalServer();
2279 if (replaced) {
2280 console.log(
2281 `Replacing running Executor ${replaced.kind} at ${replaced.connection.origin} (pid ${replaced.pid})...`,
2178 );2282 );
2179 }2283 }
2284 }
2180 2285
2181 console.log(`Executor is now running as a background service at ${origin}.`);2286 const path = yield* PlatformPath.Path;
2182 console.log("It keeps serving after you quit the app and restarts on login.");2287 const dataDir = resolveExecutorDataDir(path);
2183 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);2288 const origin = supervisedServiceOrigin(port);
2184 }),2289 console.log("Installing Executor as a background service...");
2290 console.log(`Service manager: ${serviceManagerName(backend.platform)}`);
2291 console.log(`Web UI: ${origin}`);
2292 console.log(`Data directory: ${dataDir}`);
2293 console.log(`Logs: ${path.join(dataDir, "logs")}`);
2294 console.log("");
2295 console.log("Writing the service definition and starting Executor...");
2296
2297 // The unit carries no secret: the supervised daemon mints/loads its bearer
2298 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2299 // the same file — so reachability is the credential-free /api/health probe.
2300 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2301
2302 console.log(`Waiting for Executor to become reachable at ${origin}...`);
2303 const reachable = yield* waitForReachable({
2304 check: isServerReachable(origin),
2305 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2306 intervalMs: DAEMON_BOOT_POLL_MS,
2307 });
2308 if (!reachable) {
2309 return yield* Effect.fail(
2310 new Error(
2311 [
2312 `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2313 `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2314 ].join("\n"),
2315 ),
2316 );
2317 }
2318
2319 console.log(`Executor is now running as a background service at ${origin}.`);
2320 console.log("It keeps serving after you quit the app and restarts on login.");
2321 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2322 });
2323
2324const serviceInstallCommand = Command.make(
2325 "install",
2326 {
2327 port: servicePortOption(),
2328 },
2329 ({ port }) => installService(port, "service install"),
2185).pipe(2330).pipe(
2186 Command.withDescription("Install and start Executor as an OS-supervised background service"),2331 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2187);2332);
@@ -2189,7 +2334,21 @@ const serviceInstallCommand = Command.make( · 1 unmodified line
2189const serviceUninstallCommand = Command.make("uninstall", {}, () =>2334const serviceUninstallCommand = Command.make("uninstall", {}, () =>
2190 Effect.gen(function* () {2335 Effect.gen(function* () {
2191 const backend = getServiceBackend();2336 const backend = getServiceBackend();
2337 const wasRunning = backend.automated
2338 ? yield* backend.status().pipe(
2339 Effect.map((status) => status.running),
2340 Effect.catchCause(() => Effect.succeed(false)),
2341 )
2342 : false;
2192 yield* backend.uninstall();2343 yield* backend.uninstall();
2344 if (wasRunning) {
2345 const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" });
2346 if (stopped) {
2347 console.log(
2348 `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`,
2349 );
2350 }
2351 }
2193 console.log("Executor background service uninstalled.");2352 console.log("Executor background service uninstalled.");
2194 }),2353 }),
2195).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));2354).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));
@@ -2240,6 +2399,16 @@ const serviceCommand = Command.make("service").pipe( · 44 unmodified lines
2240 Command.withDescription("Manage the OS-supervised background service"),2399 Command.withDescription("Manage the OS-supervised background service"),
2241);2400);
2242 2401
2402const installCommand = Command.make(
2403 "install",
2404 {
2405 port: servicePortOption(),
2406 },
2407 ({ port }) => installService(port, "install"),
2408).pipe(
2409 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2410);
2411
2243// ---------------------------------------------------------------------------2412// ---------------------------------------------------------------------------
2244// Root command2413// Root command
2245// ---------------------------------------------------------------------------2414// ---------------------------------------------------------------------------
@@ -2263,17 +2432,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines
2263 execFile(cmd, [...args], () => {});2432 execFile(cmd, [...args], () => {});
2264 });2433 });
2265 2434
2266/**2435const printNoRunningLocalWebApp = (): void => {
2267 * `executor open` — the friendly way back in. Reads the running local server's2436 console.log("Executor is not running.");
2268 * manifest and opens the browser straight to its `?_token=` URL, so the user2437 console.log("");
2269 * never has to copy a bearer token out of a terminal or auth.json by hand.2438 console.log("Install and start the background service:");
2270 */2439 console.log(` ${cliPrefix} install`);
2271const openCommand = Command.make("open", {}, () =>2440 console.log("");
2441 console.log("Then open the web UI:");
2442 console.log(` ${cliPrefix} web`);
2443 console.log("");
2444 console.log("For a temporary foreground server:");
2445 console.log(` ${cliPrefix} web --foreground`);
2446};
2447
2448const openRunningLocalWebApp = (): Effect.Effect<
2449 void,
2450 never,
2451 FileSystem.FileSystem | PlatformPath.Path
2452> =>
2272 Effect.gen(function* () {2453 Effect.gen(function* () {
2273 const manifest = yield* readLocalServerManifest();2454 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2274 if (!manifest || !isPidAlive(manifest.pid)) {2455 if (!manifest) {
2275 console.log("No local Executor server is running.");2456 printNoRunningLocalWebApp();
2276 console.log(`Start one with: ${cliPrefix} web`);
2277 return;2457 return;
2278 }2458 }
2279 const { origin, auth } = manifest.connection;2459 const { origin, auth } = manifest.connection;
@@ -2281,8 +2461,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line
2281 const url = token ? `${origin}/?_token=${token}` : origin;2461 const url = token ? `${origin}/?_token=${token}` : origin;
2282 console.log(`Opening ${url}`);2462 console.log(`Opening ${url}`);
2283 yield* openInBrowser(url);2463 yield* openInBrowser(url);
2284 }),2464 });
2285).pipe(2465
2466/**
2467 * `executor open` — the friendly way back in. Reads the running local server's
2468 * manifest and opens the browser straight to its `?_token=` URL, so the user
2469 * never has to copy a bearer token out of a terminal or auth.json by hand.
2470 */
2471const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe(
2286 Command.withDescription("Open the running Executor web app in your browser, already signed in"),2472 Command.withDescription("Open the running Executor web app in your browser, already signed in"),
2287);2473);
2288 2474
@@ -2291,6 +2477,7 @@ const root = Command.make("executor").pipe( · 2 unmodified lines
2291 callCommand,2477 callCommand,
2292 resumeCommand,2478 resumeCommand,
2293 toolsCommand,2479 toolsCommand,
2480 installCommand,
2294 serverCommand,2481 serverCommand,
2295 webCommand,2482 webCommand,
2296 daemonCommand,2483 daemonCommand,
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+60−23
@@ -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)
@@ -270,8 +291,12 @@ const makeLaunchdBackend = (): ServiceBackend => { · 105 unmodified lines
270 yield* fs.writeFileString(plistFile, plist, { mode: 0o600 });291 yield* fs.writeFileString(plistFile, plist, { mode: 0o600 });
271 292
272 // Re-bootstrap cleanly: a stale registration from a prior install would293 // Re-bootstrap cleanly: a stale registration from a prior install would
273 // make `bootstrap` fail with "service already loaded".294 // make `bootstrap` fail with "service already loaded". `service
295 // uninstall` also records the label as disabled in launchd's override
296 // database; clear that before bootstrapping or a reinstall can fail with
297 // launchctl's generic "Bootstrap failed: 5" error.
274 yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);298 yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
299 yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
275 const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]);300 const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]);
276 if (bootstrap.code !== 0) {301 if (bootstrap.code !== 0) {
277 return yield* Effect.fail(302 return yield* Effect.fail(
@@ -280,7 +305,6 @@ const makeLaunchdBackend = (): ServiceBackend => { · 2 unmodified lines
280 ),305 ),
281 );306 );
282 }307 }
283 yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
284 }),308 }),
285 uninstall: () =>309 uninstall: () =>
286 Effect.gen(function* () {310 Effect.gen(function* () {
@@ -340,11 +364,24 @@ export interface SystemdUnitOptions { · 53 unmodified lines
340 readonly stderrPath: string;364 readonly stderrPath: string;
341}365}
342 366
367const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
368
369const systemdQuote = (value: string): string => {
370 if (SYSTEMD_BARE_VALUE.test(value)) return value;
371 const escaped = value
372 .replaceAll("\\", "\\\\")
373 .replaceAll('"', '\\"')
374 .replaceAll("\n", "\\n")
375 .replaceAll("\r", "\\r")
376 .replaceAll("\t", "\\t");
377 return `"${escaped}"`;
378};
379
343/** Render a systemd --user unit. Pure (snapshot-tested). */380/** Render a systemd --user unit. Pure (snapshot-tested). */
344export const generateSystemdUnit = (options: SystemdUnitOptions): string => {381export const generateSystemdUnit = (options: SystemdUnitOptions): string => {
345 const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");382 const execStart = options.execStart.map(systemdQuote).join(" ");
346 const env = Object.entries(options.environment)383 const env = Object.entries(options.environment)
347 .map(([key, value]) => `Environment=${key}=${value}`)384 .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
348 .join("\n");385 .join("\n");
349 return `[Unit]386 return `[Unit]
350Description=Executor supervised daemon387Description=Executor supervised daemon
@@ -354,9 +391,9 @@ After=default.target · 3 unmodified lines
354Type=simple391Type=simple
355ExecStart=${execStart}392ExecStart=${execStart}
356${env}393${env}
357WorkingDirectory=${options.workingDirectory}394WorkingDirectory=${systemdQuote(options.workingDirectory)}
358StandardOutput=append:${options.stdoutPath}395StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)}
359StandardError=append:${options.stderrPath}396StandardError=${systemdQuote(`append:${options.stderrPath}`)}
360Restart=on-failure397Restart=on-failure
361RestartSec=5s398RestartSec=5s
362 399
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+96−21
@@ -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) => {
@@ -415,12 +478,24 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> => · 28 unmodified lines
415};478};
416 479
417const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {480const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => {
418 // A supervised daemon isn't ours to restart — just reload the window against481 // A supervised daemon owns its own process lifetime. Re-installing the unit
419 // the same endpoint instead of tearing down a process we don't own.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;
485 await installSupervisedService({
486 port,
487 dataDir: DESKTOP_DATA_DIR,
488 });
489 const next = await waitForSupervisedAttach(30_000, { port });
490 if (!next) {
491 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call
492 throw new Error("Supervised daemon failed to restart — see Settings");
493 }
494 connection = next;
495 installBearerAuthHeader(next.baseUrl, next.authToken);
421 const window = liveMainWindow();496 const window = liveMainWindow();
422 if (window) await window.loadURL(connection.baseUrl);497 if (window) await window.loadURL(webUrlForConnection(next));
423 return toDesktopServerConnection(connection);498 return toDesktopServerConnection(next);
424 }499 }
425 if (connection) {500 if (connection) {
426 await stopConnection(connection);501 await stopConnection(connection);
@@ -434,7 +509,7 @@ const restartSidecarAndReload = async (): Promise<DesktopServerConnection> => { · 7 unmodified lines
434 connection = next;509 connection = next;
435 installBearerAuthHeader(next.baseUrl, next.authToken);510 installBearerAuthHeader(next.baseUrl, next.authToken);
436 const window = liveMainWindow();511 const window = liveMainWindow();
437 if (window) await window.loadURL(next.baseUrl);512 if (window) await window.loadURL(webUrlForConnection(next));
438 return toDesktopServerConnection(next);513 return toDesktopServerConnection(next);
439};514};
440 515
@@ -446,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec · 5 unmodified lines
446 key: "desktop-sidecar",521 key: "desktop-sidecar",
447 origin: conn.baseUrl,522 origin: conn.baseUrl,
448 apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,523 apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`,
449 displayName: "Desktop sidecar",524 displayName: "Local Executor",
450});525});
451 526
452const registerIpcHandlers = () => {527const registerIpcHandlers = () => {
@@ -475,14 +550,14 @@ const registerIpcHandlers = () => { · 22 unmodified lines
475 connection = active;550 connection = active;
476 installBearerAuthHeader(active.baseUrl, active.authToken);551 installBearerAuthHeader(active.baseUrl, active.authToken);
477 const window = liveMainWindow();552 const window = liveMainWindow();
478 if (window) await window.loadURL(active.baseUrl);553 if (window) await window.loadURL(webUrlForConnection(active));
479 return toDesktopServerConnection(active);554 return toDesktopServerConnection(active);
480 }555 }
481 return restartSidecarAndReload();556 return restartSidecarAndReload();
482 });557 });
483 // Background-service control surface (macOS) — lets a Settings toggle enable558 // Background-service control surface — lets a Settings toggle enable or
484 // or disable the supervised daemon. Disabling tears down the service and559 // disable the supervised daemon. Disabling tears down the service and falls
485 // falls back to a managed sidecar on next launch.560 // back to a managed sidecar on next launch.
486 ipcMain.handle("executor:service:status", () => supervisedServiceStatus());561 ipcMain.handle("executor:service:status", () => supervisedServiceStatus());
487 ipcMain.handle(562 ipcMain.handle(
488 "executor:service:set-enabled",563 "executor:service:set-enabled",
@@ -501,7 +576,7 @@ const registerIpcHandlers = () => { · 12 unmodified lines
501 armSupervisedMonitor();576 armSupervisedMonitor();
502 installBearerAuthHeader(next.baseUrl, next.authToken);577 installBearerAuthHeader(next.baseUrl, next.authToken);
503 const window = liveMainWindow();578 const window = liveMainWindow();
504 if (window) await window.loadURL(next.baseUrl);579 if (window) await window.loadURL(webUrlForConnection(next));
505 }580 }
506 return true;581 return true;
507 }582 }
@@ -762,7 +837,7 @@ const boot = async () => { · 254 unmodified lines
762 // 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
763 // 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
764 // 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
765 // "another server owns the data dir → fatal error" path. Packaged macOS only;840 // "another server owns the data dir → fatal error" path. Packaged builds only;
766 // dev and unsupported platforms keep managed-spawn.841 // dev and unsupported platforms keep managed-spawn.
767 if (app.isPackaged) {842 if (app.isPackaged) {
768 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−182
@@ -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,84 +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 await runCommand("launchctl", ["bootout", serviceTarget(uid)]);
209 const bootstrap = await runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath()]);
210 if (bootstrap.code !== 0) {
211 // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to the boot flow
212 throw new Error(
213 `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`,
214 );
215 }117 }
216 await runCommand("launchctl", ["enable", serviceTarget(uid)]);118 serviceLog.info(`installed supervised service via bundled executor on port ${opts.port}`);
217 serviceLog.info(`installed supervised service on port ${opts.port}`);
218};119};
219 120
220export const uninstallSupervisedService = async (dataDir: string): Promise<void> => {121export const uninstallSupervisedService = async (dataDir: string): Promise<void> => {
221 const uid = currentUid();122 if (!executorAvailable()) return;
222 await runCommand("launchctl", ["bootout", serviceTarget(uid)]);123 const result = await runExecutor(["service", "uninstall"], { dataDir });
223 await runCommand("launchctl", ["disable", serviceTarget(uid)]);124 if (result.code !== 0) {
224 rmSync(plistPath(), { force: true });125 throw new Error(
225 // Clean up a legacy service.key from a pre-bearer install (best-effort).126 (result.stderr || result.stdout).trim() || "`executor service uninstall` failed.",
226 rmSync(join(dataDir, "server-control", "service.key"), { force: true });127 );
227 serviceLog.info("uninstalled supervised service");128 }
129 serviceLog.info("uninstalled supervised service via bundled executor");
228};130};
229 131
230/** Restart the supervised daemon atomically (kill + relaunch via launchd). */
231export const restartSupervisedService = async (): Promise<void> => {132export const restartSupervisedService = async (): Promise<void> => {
232 await runCommand("launchctl", ["kickstart", "-k", serviceTarget(currentUid())]);133 if (!executorAvailable()) {
134 throw new Error("Bundled executor binary is not available.");
135 }
136 const dataDir = join(app.getPath("home"), ".executor");
137 const result = await runExecutor(["service", "restart"], { dataDir });
138 if (result.code !== 0) {
139 throw new Error(
140 (result.stderr || result.stdout).trim() || "`executor service restart` failed.",
141 );
142 }
233};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+105−64
@@ -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
@@ -408,25 +440,27 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 30 unmodified lines
408 });440 });
409}441}
410 442
411/**443/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
412 * Probe whether an HTTP server is listening at `origin`. Any HTTP response —444const isDaemonReachable = async (origin: string): Promise<boolean> => {
413 * even 401/404 — means a server is up; only a network error or timeout counts445 for (let attempt = 0; attempt < 3; attempt += 1) {
414 * as unreachable. Auth is attached so a 401 still confirms liveness cleanly.446 const controller = new AbortController();
415 */447 const timer = setTimeout(() => controller.abort(), 1500);
416const isDaemonReachable = async (origin: string, authToken: string): Promise<boolean> => {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 controller = new AbortController();449 try {
418 const timer = setTimeout(() => controller.abort(), 1500);450 const response = await fetch(new URL("/api/health", origin), {
419 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal451 signal: controller.signal,
420 try {452 redirect: "manual",
421 const headers: Record<string, string> = {};453 });
422 if (authToken) headers.Authorization = `Bearer ${authToken}`;454 const body = await response.text();
423 await fetch(origin, { signal: controller.signal, headers, redirect: "manual" });455 if (response.ok && body.trim() === "ok") return true;
424 return true;456 } catch {
425 } catch {457 // retry below
426 return false;458 } finally {
427 } finally {459 clearTimeout(timer);
428 clearTimeout(timer);460 }
461 if (attempt < 2) await delay(150);
429 }462 }
463 return false;
430};464};
431 465
432/**466/**
@@ -452,7 +486,11 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 19 unmodified lines
452 const origin = manifest.connection.origin;486 const origin = manifest.connection.origin;
453 const auth = manifest.connection.auth;487 const auth = manifest.connection.auth;
454 const authToken = auth && auth.kind === "bearer" ? auth.token : "";488 const authToken = auth && auth.kind === "bearer" ? auth.token : "";
455 if (!(await isDaemonReachable(origin, authToken))) return null;489 if (!(await isDaemonReachable(origin))) {
490 sidecarLog.info(`removing stale supervised daemon manifest for unreachable ${origin}`);
491 removeManifestIfOwnedBy(dataDir, manifest.pid);
492 return null;
493 }
456 494
457 const url = new URL(origin);495 const url = new URL(origin);
458 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);496 sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`);
@@ -464,6 +502,9 @@ export async function attachToSupervisedDaemon(): Promise<SidecarConnection | nu · 5 unmodified lines
464 authToken,502 authToken,
465 child: null,503 child: null,
466 supervisedDaemon: true,504 supervisedDaemon: true,
505 ownerVersion: manifest.owner.version,
506 ownerClient: manifest.owner.client,
507 ownerExecutablePath: manifest.owner.executablePath,
467 };508 };
468}509}
469 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+9−9
@@ -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");
@@ -63,7 +61,7 @@ if (sentryDsn) { · 40 unmodified lines
63 });61 });
64}62}
65 63
66import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";64import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
67import { homedir } from "node:os";65import { homedir } from "node:os";
68import {66import {
69 normalizeExecutorServerConnection,67 normalizeExecutorServerConnection,
@@ -116,7 +114,9 @@ const writeSupervisedManifest = (port: number, token: string) => { · 46 unmodified lines
116 executablePath: process.execPath || null,114 executablePath: process.execPath || null,
117 },115 },
118 }),116 }),
117 { mode: 0o600 },
119 );118 );
119 chmodSync(manifestPath, 0o600);
120};120};
121 121
122const removeOwnManifest = () => {122const removeOwnManifest = () => {
apps/host-selfhost/src/mcp/index.ts+35−6
@@ -1,7 +1,12 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { IdentityProvider } from "@executor-js/api/server";3import { IdentityProvider } from "@executor-js/api/server";
4import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";4import type {
5 McpAuthProvider,
6 McpErrorReporter,
7 McpSessionStore,
8 Principal,
9} from "@executor-js/host-mcp";
5 10
6import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";11import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
7import type { SelfHostDbHandle } from "../db/self-host-db";12import type { SelfHostDbHandle } from "../db/self-host-db";
@@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { · 56 unmodified lines
64const jsonResponse = (value: unknown, status: number): Response =>69const jsonResponse = (value: unknown, status: number): Response =>
65 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });70 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
66 71
72const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
73 (role ?? "user")
74 .split(",")
75 .map((r) => r.trim())
76 .filter((r) => r.length > 0);
77
78type BetterAuthSession = NonNullable<
79 Awaited<ReturnType<BetterAuthHandle["auth"]["api"]["getSession"]>>
80>;
81
82const principalFromSession = (
83 resolved: BetterAuthSession,
84 betterAuth: BetterAuthHandle,
85): Principal => ({
86 accountId: resolved.user.id,
87 organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId,
88 organizationName: betterAuth.organizationName,
89 email: resolved.user.email,
90 name: resolved.user.name ?? null,
91 avatarUrl: resolved.user.image ?? null,
92 roles: parseRoles(resolved.user.role ?? null),
93});
94
67/**95/**
68 * Gate the browser-approval endpoints behind a valid Better Auth session (the96 * Gate the browser-approval endpoints behind a valid Better Auth session (the
69 * console page calls them with the user's cookie), then delegate to the97 * console page calls them with the user's cookie), then delegate to the
70 * in-process store's paused/resume handlers. Single-tenant: any authenticated98 * in-process store's paused/resume handlers with the resolved principal so the
71 * user of the one org may act on a session it still holds — the store confirms99 * store can enforce MCP session ownership before exposing or recording a
72 * the execution belongs to the addressed session before recording.100 * browser-approval decision.
73 */101 */
74const makeApprovalHandler =102const makeApprovalHandler =
75 (103 (
@@ -85,10 +113,11 @@ const makeApprovalHandler = · 9 unmodified lines
85 }).pipe(Effect.orElseSucceed(() => null)),113 }).pipe(Effect.orElseSucceed(() => null)),
86 );114 );
87 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);115 if (!session) return jsonResponse({ error: "Unauthorized" }, 401);
116 const principal = principalFromSession(session, betterAuth);
88 117
89 return (118 return (
90 (await store.handlePausedRequest(request)) ??119 (await store.handlePausedRequest(request, principal)) ??
91 (await store.handleApprovalRequest(request)) ??120 (await store.handleApprovalRequest(request, principal)) ??
92 jsonResponse({ error: "Not found" }, 404)121 jsonResponse({ error: "Not found" }, 404)
93 );122 );
94 };123 };
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({
apps/local/vite.config.ts+31−13
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
5import { defineConfig, type Plugin } from "vite";5import { defineConfig, type Plugin } from "vite";
6import appPlugin from "@executor-js/app/vite";6import appPlugin from "@executor-js/app/vite";
7import { loadOrMintLocalAuthToken } from "./src/auth";7import { loadOrMintLocalAuthToken } from "./src/auth";
8import { consumeOAuthResult } from "./src/oauth-result-store";
8import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";9import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
9 10
10// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json11// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
@@ -114,20 +115,37 @@ function executorApiPlugin(): Plugin { · 103 unmodified lines
114 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);115 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
115 }116 }
116 117
117 // Strip /api prefix for Effect handlers
118 const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl;
119
120 const hasBody = req.method !== "GET" && req.method !== "HEAD";118 const hasBody = req.method !== "GET" && req.method !== "HEAD";
121 const webRequest = new Request(new URL(url, origin), {119 const webRequest = (url: string): Request =>
122 method: req.method,120 new Request(new URL(url, origin), {
123 headers,121 method: req.method,
124 body: hasBody ? Readable.toWeb(req) : undefined,122 headers,
125 duplex: hasBody ? "half" : undefined,123 body: hasBody ? Readable.toWeb(req) : undefined,
126 } as RequestInit);124 duplex: hasBody ? "half" : undefined,
127 125 } as RequestInit);
128 const response = isMcp126
129 ? await handlers.mcp.handleRequest(webRequest)127 let response: Response;
130 : await handlers.api.handler(webRequest);128 if (isMcp) {
129 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130 } else if (pathOnly === "/api/health" && req.method === "GET") {
131 response = new Response("ok", { headers: { "content-type": "text/plain" } });
132 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
133 const handler =
134 req.method === "GET"
135 ? handlers.mcp.handlePausedRequest
136 : handlers.mcp.handleApprovalRequest;
137 response = await handler(webRequest(rawUrl));
138 } else {
139 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);
140 if (awaitMatch && req.method === "GET") {
141 response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {
142 headers: { "content-type": "application/json" },
143 });
144 } else {
145 // Strip /api prefix for Effect handlers.
146 response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/"));
147 }
148 }
131 149
132 res.statusCode = response.status;150 res.statusCode = response.status;
133 response.headers.forEach((v, k) => res.setHeader(k, v));151 response.headers.forEach((v, k) => res.setHeader(k, v));
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+213−0
@@ -0,0 +1,213 @@
1/* oxlint-disable executor/no-conditional-tests -- e2e scenario uses try/finally to restore the VM service after assertions */
2// Real VM e2e for the upgrade path: `executor service install` must take over
3// a same-data-dir predecessor instead of refusing and leaving users to find a
4// pid. Runs on the CLI VM targets where the test worker can SSH into the guest
5// that globalsetup provisioned.
6import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
7import { promisify } from "node:util";
8
9import { expect } 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
30type GuestOs = "macos" | "linux" | "windows";
31
32const guestOs = (): GuestOs => {
33 const os = process.env.E2E_VM_OS;
34 if (os === "macos" || os === "linux" || os === "windows") return os;
35 throw new Error(`Unsupported E2E_VM_OS: ${os ?? "<unset>"}`);
36};
37
38const sshInvocation = (command: string): { command: string; args: ReadonlyArray<string> } => {
39 const host = process.env.E2E_CLI_VM_HOST;
40 if (!host) throw new Error("E2E_CLI_VM_HOST is not set");
41 const os = guestOs();
42 const wrapped =
43 os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command;
44 const keyPath = process.env.E2E_CLI_SSH_KEY;
45 const user = os === "windows" ? "Administrator" : "admin";
46 return keyPath
47 ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] }
48 : {
49 command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass",
50 args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped],
51 };
52};
53
54const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => {
55 const invocation = sshInvocation(command);
56 try {
57 const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], {
58 maxBuffer: 32 * 1024 * 1024,
59 });
60 return { stdout, stderr, code: 0 };
61 } catch (error) {
62 const err = error as { stdout?: string; stderr?: string; code?: number };
63 return {
64 stdout: err.stdout ?? "",
65 stderr: err.stderr ?? "",
66 code: typeof err.code === "number" ? err.code : 1,
67 };
68 }
69};
70
71const executorPath = (): string => {
72 const dir = process.env.E2E_CLI_BIN_DIR ?? (guestOs() === "windows" ? "C:/ed" : "~/ed");
73 return guestOs() === "windows" ? `${dir}/executor.exe` : `${dir}/executor`;
74};
75
76const healthStatusCommand = (): string =>
77 guestOs() === "windows"
78 ? `try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 'http://127.0.0.1:${PORT}/api/health'; [string]$r.StatusCode } catch { '000' }`
79 : `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`;
80
81const waitForGuestHealth = async (expected: boolean): Promise<boolean> => {
82 const deadline = Date.now() + 30_000;
83 for (;;) {
84 const result = await ssh(healthStatusCommand());
85 const healthy = result.stdout.trim() === "200";
86 if (healthy === expected) return true;
87 if (Date.now() >= deadline) return false;
88 await new Promise((resolve) => setTimeout(resolve, 500));
89 }
90};
91
92const listenerPid = async (): Promise<string> =>
93 (
94 await ssh(
95 guestOs() === "windows"
96 ? `$c = Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $c) { [string]$c.OwningProcess }`
97 : `lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`,
98 )
99 ).stdout.trim();
100
101const uninstallServiceCommand = (exe: string): string =>
102 guestOs() === "windows"
103 ? `& '${exe}' service uninstall *> 'C:/Windows/Temp/takeover-uninstall.log'; exit 0`
104 : `${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`;
105
106interface PredecessorHandle {
107 readonly close: () => void;
108 readonly diagnostics: () => Promise<string>;
109}
110
111const appendChunk = (existing: string, chunk: Buffer): string =>
112 `${existing}${chunk.toString("utf8")}`.slice(-16_000);
113
114const windowsPredecessorCommand = (exe: string): string =>
115 `& '${exe}' daemon run --foreground --port ${PORT} --hostname 127.0.0.1`;
116
117const startPredecessor = async (exe: string): Promise<PredecessorHandle> => {
118 if (guestOs() !== "windows") {
119 await ssh(
120 `nohup ${exe} daemon run --foreground --port ${PORT} --hostname 127.0.0.1 >/tmp/takeover-predecessor.log 2>&1 &`,
121 );
122 return {
123 close: () => {},
124 diagnostics: async () =>
125 (await ssh("cat /tmp/takeover-predecessor.log 2>/dev/null || true")).stdout.trim(),
126 };
127 }
128
129 const invocation = sshInvocation(windowsPredecessorCommand(exe));
130 const child: ChildProcessWithoutNullStreams = spawn(invocation.command, [...invocation.args], {
131 stdio: ["ignore", "pipe", "pipe"],
132 });
133 let stdout = "";
134 let stderr = "";
135 let exit: string | null = null;
136 child.stdout.on("data", (chunk: Buffer) => {
137 stdout = appendChunk(stdout, chunk);
138 });
139 child.stderr.on("data", (chunk: Buffer) => {
140 stderr = appendChunk(stderr, chunk);
141 });
142 child.on("error", (error) => {
143 exit = `spawn error: ${error.message}`;
144 });
145 child.on("exit", (code, signal) => {
146 exit = `exit code ${code ?? "<null>"} signal ${signal ?? "<null>"}`;
147 });
148
149 return {
150 close: () => {
151 if (!child.killed) child.kill();
152 },
153 diagnostics: async () =>
154 [`predecessor ssh: ${exit ?? "still running"}`, `stdout:\n${stdout}`, `stderr:\n${stderr}`]
155 .join("\n")
156 .trim(),
157 };
158};
159
160const installServiceCommand = (exe: string): string =>
161 guestOs() === "windows"
162 ? `& '${exe}' service install --port ${PORT}; exit $LASTEXITCODE`
163 : `${exe} service install --port ${PORT}`;
164
165const processAliveCommand = (pid: string): string =>
166 guestOs() === "windows"
167 ? `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }`
168 : `kill -0 ${pid} 2>/dev/null && echo alive || echo dead`;
169
170const restoreServiceCommand = (exe: string): string =>
171 guestOs() === "windows"
172 ? `& '${exe}' service install --port ${PORT} *> 'C:/Windows/Temp/takeover-restore.log'; exit 0`
173 : `${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`;
174
175scenario(
176 "CLI service install · takes over a running predecessor daemon",
177 { timeout: 180_000 },
178 Effect.promise(async () => {
179 const exe = executorPath();
180 let predecessor: PredecessorHandle | null = null;
181 try {
182 await ssh(uninstallServiceCommand(exe));
183 expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe(
184 true,
185 );
186
187 predecessor = await startPredecessor(exe);
188 const predecessorReady = await waitForGuestHealth(true);
189 expect(
190 predecessorReady,
191 `predecessor daemon became reachable\n${await predecessor.diagnostics()}`,
192 ).toBe(true);
193 const predecessorPid = await listenerPid();
194 expect(predecessorPid, "predecessor owns the service port").not.toBe("");
195
196 const install = await ssh(installServiceCommand(exe));
197 expect(
198 install.code,
199 `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`,
200 ).toBe(0);
201 expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true);
202
203 const ownerPid = await listenerPid();
204 const predecessorAlive = (await ssh(processAliveCommand(predecessorPid))).stdout.trim();
205 expect(predecessorAlive, "predecessor process was stopped").toBe("dead");
206 expect(ownerPid, "the service now owns the port").not.toBe("");
207 expect(ownerPid, "the service is a different process").not.toBe(predecessorPid);
208 } finally {
209 predecessor?.close();
210 await ssh(restoreServiceCommand(exe));
211 }
212 }),
213);
e2e/desktop-packaged/supervised-attach.test.ts+273−72
@@ -4,24 +4,22 @@ · 3 unmodified lines
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+934−0
@@ -0,0 +1,934 @@
1// Packaged desktop supervised-daemon regressions. These run against the real
2// electron-builder bundle and its bundled executor because the supervised attach
3// path is production-only (`app.isPackaged`).
4import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process";
5import {
6 chmodSync,
7 existsSync,
8 mkdirSync,
9 mkdtempSync,
10 readFileSync,
11 rmSync,
12 statSync,
13 writeFileSync,
14} from "node:fs";
15import { createServer, type IncomingMessage } from "node:http";
16import net from "node:net";
17import { homedir, tmpdir } from "node:os";
18import { dirname, join } from "node:path";
19import { promisify } from "node:util";
20
21import { expect, it } from "@effect/vitest";
22import { Effect } from "effect";
23import {
24 normalizeExecutorServerConnection,
25 serializeExecutorLocalServerManifest,
26} from "@executor-js/sdk/shared";
27
28import { scenario } from "../src/scenario";
29import { RunDir } from "../src/services";
30import { waitForHttp } from "../setup/boot";
31
32const execFileAsync = promisify(execFile);
33const SERVICE_LABEL = "sh.executor.daemon";
34
35interface PackagedExecutorBridge {
36 readonly getSettings: () => Promise<{ readonly port: number }>;
37 readonly updateSettings: (patch: { readonly port: number }) => Promise<unknown>;
38 readonly restartServer: () => Promise<unknown>;
39 readonly getServerConnection: () => Promise<{ readonly origin: string } | null>;
40}
41
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
209declare global {
210 interface Window {
211 readonly executor: PackagedExecutorBridge;
212 }
213}
214
215const appExe = process.env.E2E_DESKTOP_APP_EXE;
216const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN;
217
218const guiAvailable = (): boolean => {
219 if (process.platform === "darwin") {
220 try {
221 return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua";
222 } catch {
223 return false;
224 }
225 }
226 if (process.platform === "linux")
227 return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
228 return true;
229};
230
231const packagedSingleInstanceAvailable = (): boolean => {
232 if (process.platform !== "darwin" || !appExe) return true;
233 try {
234 const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], {
235 encoding: "utf8",
236 })
237 .split("\n")
238 .filter(Boolean);
239 return !lines.some((line) => !line.includes(appExe));
240 } catch {
241 return true;
242 }
243};
244
245const requireBundle = (): { readonly app: string; readonly executor: string } => {
246 if (!appExe || !executorBin) {
247 throw new Error(
248 "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?",
249 );
250 }
251 return { app: appExe, executor: executorBin };
252};
253
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 }
341};
342
343const freePort = (): Promise<number> =>
344 new Promise((resolve, reject) => {
345 const srv = net.createServer();
346 srv.on("error", reject);
347 srv.listen(0, "127.0.0.1", () => {
348 const port = (srv.address() as net.AddressInfo).port;
349 srv.close(() => resolve(port));
350 });
351 });
352
353interface DaemonStart {
354 readonly child: ChildProcess;
355 readonly ready: boolean;
356 readonly stderr: string;
357}
358
359const startSupervisedDaemon = (
360 env: NodeJS.ProcessEnv,
361 port: number,
362 hostname = "127.0.0.1",
363): Promise<DaemonStart> =>
364 new Promise((resolve) => {
365 const { executor } = requireBundle();
366 const child = spawn(
367 executor,
368 ["daemon", "run", "--foreground", "--port", String(port), "--hostname", hostname],
369 { env, stdio: ["ignore", "pipe", "pipe"] },
370 );
371 let stderr = "";
372 let settled = false;
373 const settle = (ready: boolean) => {
374 if (settled) return;
375 settled = true;
376 resolve({ child, ready, stderr });
377 };
378 const timer = setTimeout(() => settle(false), 60_000);
379 child.stdout.on("data", (chunk: Buffer) => {
380 if (/Daemon ready on http:\/\//.test(chunk.toString())) {
381 clearTimeout(timer);
382 settle(true);
383 }
384 });
385 child.stderr.on("data", (chunk: Buffer) => {
386 stderr += chunk.toString();
387 });
388 child.on("exit", () => {
389 clearTimeout(timer);
390 settle(false);
391 });
392 });
393
394const stopProcess = async (child: ChildProcess | undefined): Promise<void> => {
395 if (!child || child.exitCode !== null || child.signalCode !== null) return;
396 await new Promise<void>((resolve) => {
397 const timeout = setTimeout(() => {
398 child.kill("SIGKILL");
399 resolve();
400 }, 5_000);
401 child.once("exit", () => {
402 clearTimeout(timeout);
403 resolve();
404 });
405 child.kill("SIGTERM");
406 });
407};
408
409const waitForPageWebSocket = async (debugPort: string): Promise<string> => {
410 const deadline = Date.now() + 120_000;
411 for (;;) {
412 const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`)
413 .then((response) => (response.ok ? response.json() : []))
414 .catch(() => [])) as ReadonlyArray<CdpTarget>;
415 const page = targets.find(
416 (target) =>
417 target.type === "page" &&
418 target.webSocketDebuggerUrl &&
419 !target.url.startsWith("devtools://"),
420 );
421 if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
422 if (Date.now() >= deadline) {
423 throw new Error("Timed out waiting for packaged app page CDP target");
424 }
425 await new Promise((resolve) => setTimeout(resolve, 100));
426 }
427};
428
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 });
615 }
616};
617
618scenario(
619 "Desktop packaged supervised daemon · server manifest is owner-only",
620 { timeout: 180_000 },
621 Effect.promise(async () => {
622 requireBundle();
623 const home = mkdtempSync(join(tmpdir(), "executor-pkg-manifest-mode-"));
624 const dataDir = join(home, ".executor");
625 const manifestPath = join(dataDir, "server-control", "server.json");
626 const port = await freePort();
627 let daemon: ChildProcess | undefined;
628 const previousUmask = process.umask(0o022);
629 try {
630 const started = await startSupervisedDaemon(
631 {
632 ...process.env,
633 HOME: home,
634 EXECUTOR_SUPERVISED: "1",
635 EXECUTOR_DATA_DIR: dataDir,
636 EXECUTOR_AUTH_TOKEN: "manifest-mode-token",
637 EXECUTOR_CLIENT: "desktop",
638 },
639 port,
640 );
641 daemon = started.child;
642 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(
643 true,
644 );
645 await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });
646
647 const mode = statSync(manifestPath).mode & 0o777;
648 expect(
649 mode.toString(8).padStart(3, "0"),
650 "server.json embeds the bearer and must be owner read/write only",
651 ).toBe("600");
652 } finally {
653 process.umask(previousUmask);
654 daemon?.kill("SIGTERM");
655 rmSync(home, { recursive: true, force: true });
656 }
657 }),
658);
659
660if (!guiAvailable() || !packagedSingleInstanceAvailable()) {
661 it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {});
662} else {
663 scenario(
664 "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer",
665 { timeout: 240_000 },
666 Effect.promise(() => runStaleManifestProbe()),
667 );
668
669 scenario(
670 "Desktop packaged supervised settings · changing the port moves the active daemon",
671 { timeout: 300_000 },
672 Effect.gen(function* () {
673 const runDir = yield* RunDir;
674 yield* Effect.promise(() => runSupervisedPortSetting(runDir));
675 }),
676 );
677
678 scenario(
679 "Desktop packaged supervised attach · integrations load through the CLI daemon with stale profiles",
680 { timeout: 240_000 },
681 Effect.gen(function* () {
682 const runDir = yield* RunDir;
683 yield* Effect.promise(() => runSupervisedIntegrationsLoad(runDir));
684 }),
685 );
686}
687
688const runStaleManifestProbe = async () => {
689 const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-"));
690 const dataDir = join(home, ".executor");
691 const controlDir = join(dataDir, "server-control");
692 const manifestPath = join(controlDir, "server.json");
693 const token = "stale-manifest-leaked-token";
694 const launchdSnapshot = captureLaunchdService();
695 const requests: Array<{ readonly url: string; readonly authorization: string | null }> = [];
696 let resolveFirst!: () => void;
697 const firstRequest = new Promise<void>((resolve) => {
698 resolveFirst = resolve;
699 });
700 const server = createServer((req: IncomingMessage, res) => {
701 requests.push({
702 url: req.url ?? "/",
703 authorization: req.headers.authorization ?? null,
704 });
705 resolveFirst();
706 res.writeHead(200, { "content-type": "text/html" });
707 res.end("<!doctype html><title>stale daemon</title><body>stale daemon</body>");
708 });
709 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
710 const port = (server.address() as net.AddressInfo).port;
711 let appProcess: ChildProcess | undefined;
712 let appOutput = "";
713
714 try {
715 mkdirSync(controlDir, { recursive: true });
716 writeFileSync(
717 join(controlDir, "server.json"),
718 serializeExecutorLocalServerManifest({
719 version: 1,
720 kind: "cli-daemon",
721 pid: process.pid,
722 startedAt: new Date().toISOString(),
723 dataDir,
724 scopeDir: dataDir,
725 connection: normalizeExecutorServerConnection({
726 origin: `http://127.0.0.1:${port}`,
727 displayName: "Stale daemon",
728 auth: { kind: "bearer", token },
729 }),
730 owner: { client: "cli", version: null, executablePath: null },
731 }),
732 { mode: 0o600 },
733 );
734
735 appProcess = spawn(requireBundle().app, [], {
736 env: packagedAppEnv(home),
737 stdio: ["ignore", "pipe", "pipe"],
738 });
739 const collectOutput = (chunk: Buffer) => {
740 appOutput = (appOutput + chunk.toString()).slice(-8_192);
741 };
742 appProcess.stdout?.on("data", collectOutput);
743 appProcess.stderr?.on("data", collectOutput);
744
745 const probed = await Promise.race([
746 firstRequest.then(() => true),
747 new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)),
748 ]);
749 expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true);
750
751 expect(
752 requests[0]?.authorization ?? null,
753 "the stale-manifest reachability probe must not disclose the saved bearer",
754 ).toBeNull();
755
756 const manifestRemoved = await waitUntil(() => !existsSync(manifestPath), 15_000);
757 expect(
758 manifestRemoved,
759 "a live pid with a failed health probe must be removed before desktop falls back",
760 ).toBe(true);
761 } finally {
762 await stopProcess(appProcess);
763 await restoreLaunchdService(launchdSnapshot);
764 await new Promise<void>((resolve) => server.close(() => resolve()));
765 rmSync(home, { recursive: true, force: true });
766 }
767};
768
769const runSupervisedPortSetting = async (runDir: string) => {
770 const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-"));
771 const dataDir = join(home, ".executor");
772 const launchdSnapshot = captureLaunchdService();
773 const oldPort = await freePort();
774 const newPort = await freePort();
775 let daemon: ChildProcess | undefined;
776 let app: PackagedApp | undefined;
777
778 try {
779 const started = await startSupervisedDaemon(
780 {
781 ...process.env,
782 HOME: home,
783 EXECUTOR_SUPERVISED: "1",
784 EXECUTOR_DATA_DIR: dataDir,
785 EXECUTOR_AUTH_TOKEN: "port-setting-token",
786 EXECUTOR_CLIENT: "desktop",
787 },
788 oldPort,
789 );
790 daemon = started.child;
791 expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
792 await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 });
793
794 app = await launchPackaged(home);
795 let page = app.cdp;
796 await page.waitForText("Settings", 120_000);
797 await openDesktopSettings(page);
798 await assertDesktopSettingsScrolls(page);
799 await page.screenshot(join(runDir, "01-attached-settings.png"));
800
801 const before = await page.evaluate<{ readonly origin: string } | null>(
802 "window.executor.getServerConnection()",
803 );
804 expect(new URL(before!.origin).port, "test starts attached to the original port").toBe(
805 String(oldPort),
806 );
807
808 await page.evaluate(`window.executor.updateSettings({ port: ${JSON.stringify(newPort)} })`);
809
810 await page
811 .evaluate("window.executor.restartServer().catch(() => undefined)")
812 .catch(() => undefined);
813 page = await reconnectPackagedPage(app);
814 await page.waitForText("Settings", 120_000);
815
816 const after = await page.evaluate<{
817 readonly settings: { readonly port: number };
818 readonly connection: { readonly origin: string } | null;
819 }>(
820 "(async () => ({ settings: await window.executor.getSettings(), connection: await window.executor.getServerConnection() }))()",
821 );
822
823 expect(after.settings.port, "the setting was persisted").toBe(newPort);
824 expect(
825 new URL(after.connection!.origin).port,
826 "after restart, the active supervised daemon should be serving on the saved port",
827 ).toBe(String(newPort));
828 await page.screenshot(join(runDir, "02-restarted-on-new-port.png"));
829 } finally {
830 await closePackaged(app);
831 await stopProcess(daemon);
832 await restoreLaunchdService(launchdSnapshot);
833 rmSync(home, { recursive: true, force: true });
834 }
835};
836
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));
928 } finally {
929 await closePackaged(app);
930 await stopProcess(daemon);
931 await restoreLaunchdService(launchdSnapshot);
932 rmSync(home, { recursive: true, force: true });
933 }
934};
e2e/local/auth.test.ts+10−8
@@ -1,16 +1,18 @@
1// Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way1// Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way
2// a human tests it: run the dev CLI in a real terminal, watch `executor web`2// a human tests it: run the dev CLI in a real terminal, watch
3// print its one-time `?_token=` URL, then drive a browser against it. Two clean3// `executor web --foreground` print its one-time `?_token=` URL, then drive a
4// stories, each its own film (terminal.cast + session.mp4 spliced by4// browser against it. Two clean stories, each its own film (terminal.cast +
5// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`):5// session.mp4 spliced by scenario.ts), each booting its OWN temporary server
6// (own data dir, `--port 0`):
6//7//
7// 1. The CLI's ?_token URL boots straight into an authenticated console.8// 1. The CLI's ?_token URL boots straight into an authenticated console.
8// 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the9// 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the
9// token connects.10// token connects.
10//11//
11// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal12// `withLocalServer` (shared helper) runs `executor web --foreground` in a
12// and hands the printed URL to a body; the terminal stays up until the body is13// recorded terminal and hands the printed URL to a body; the terminal stays up
13// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes.14// until the body is done, then Ctrl-C shuts it (and its vite child) down so the
15// PTY closes.
14import { expect } from "@effect/vitest";16import { expect } from "@effect/vitest";
15import { Effect } from "effect";17import { Effect } from "effect";
16 18
@@ -30,7 +32,7 @@ scenario( · 13 unmodified lines
30 32
31 yield* withLocalServer(cli, runDir, ({ url, token }) =>33 yield* withLocalServer(cli, runDir, ({ url, token }) =>
32 browser.session(identity, async ({ page, step }) => {34 browser.session(identity, async ({ page, step }) => {
33 await step("Open the ?_token URL printed by executor web", async () => {35 await step("Open the ?_token URL printed by executor web --foreground", async () => {
34 await page.goto(url, { waitUntil: "domcontentloaded" });36 await page.goto(url, { waitUntil: "domcontentloaded" });
35 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });37 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
36 // Integrations actually LOAD (the built-in Executor source) — proves38 // Integrations actually LOAD (the built-in Executor source) — proves
e2e/local/cli-open-stale-manifest.test.ts+93−0
@@ -0,0 +1,93 @@
1// Local CLI: `executor open` must not trust a live pid in server.json until it
2// has proven the recorded endpoint is actually the running Executor server.
3import { execFile } from "node:child_process";
4import { randomBytes } from "node:crypto";
5import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6import net from "node:net";
7import { tmpdir } from "node:os";
8import { delimiter, join } from "node:path";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import {
15 normalizeExecutorServerConnection,
16 serializeExecutorLocalServerManifest,
17} from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20
21const execFileAsync = promisify(execFile);
22const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
23
24const freePort = (): Promise<number> =>
25 new Promise((resolve, reject) => {
26 const srv = net.createServer();
27 srv.on("error", reject);
28 srv.listen(0, "127.0.0.1", () => {
29 const port = (srv.address() as net.AddressInfo).port;
30 srv.close(() => resolve(port));
31 });
32 });
33
34const openerName = (): string =>
35 process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
36
37scenario(
38 "CLI open · a stale manifest cannot print or open the saved bearer URL",
39 { timeout: 120_000 },
40 Effect.promise(async () => {
41 const dataDir = mkdtempSync(join(tmpdir(), "executor-open-stale-"));
42 const openerDir = mkdtempSync(join(tmpdir(), "executor-open-shim-"));
43 try {
44 const port = await freePort();
45 const token = `stale-token-${randomBytes(4).toString("hex")}`;
46 mkdirSync(join(dataDir, "server-control"), { recursive: true });
47 writeFileSync(
48 join(dataDir, "server-control", "server.json"),
49 serializeExecutorLocalServerManifest({
50 version: 1,
51 kind: "foreground",
52 pid: process.pid,
53 startedAt: new Date().toISOString(),
54 dataDir,
55 scopeDir: dataDir,
56 connection: normalizeExecutorServerConnection({
57 origin: `http://127.0.0.1:${port}`,
58 displayName: "Stale test server",
59 auth: { kind: "bearer", token },
60 }),
61 owner: { client: "cli", version: null, executablePath: null },
62 }),
63 { mode: 0o600 },
64 );
65
66 const openerPath = join(openerDir, openerName());
67 writeFileSync(openerPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
68 chmodSync(openerPath, 0o755);
69
70 const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "open"], {
71 cwd: repoRoot,
72 env: {
73 ...process.env,
74 EXECUTOR_DATA_DIR: dataDir,
75 PATH: `${openerDir}${delimiter}${process.env.PATH ?? ""}`,
76 },
77 });
78 const output = `${stdout}\n${stderr}`;
79 expect(output, "stale endpoint should be rejected before printing the token URL").toContain(
80 "Executor is not running.",
81 );
82 expect(output, "the recovery path should point users at durable setup").toContain("install");
83 expect(output, "the old foreground behavior should remain discoverable").toContain(
84 "web --foreground",
85 );
86 expect(output, "the stale bearer must not be printed").not.toContain(token);
87 expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`);
88 } finally {
89 rmSync(openerDir, { recursive: true, force: true });
90 rmSync(dataDir, { recursive: true, force: true });
91 }
92 }),
93);
e2e/local/cli-systemd-unit-escaping.test.ts+44−0
@@ -0,0 +1,44 @@
1// Artifact-level e2e for the Linux service install surface: the emitted
2// systemd unit must not contain raw, unescaped paths or environment values
3// whose spaces/quotes change systemd tokenization at boot.
4import { expect } from "@effect/vitest";
5import { Effect } from "effect";
6
7import { generateSystemdUnit } from "../../apps/cli/src/service";
8import { scenario } from "../src/scenario";
9
10scenario(
11 "CLI service · generated systemd units escape paths and environment values",
12 {},
13 Effect.sync(() => {
14 const unit = generateSystemdUnit({
15 execStart: [
16 '/home/alice/Executor "Beta"/executor',
17 "daemon",
18 "run",
19 "--foreground",
20 "--port",
21 "4789",
22 ],
23 environment: {
24 EXECUTOR_SUPERVISED: "1",
25 EXECUTOR_DATA_DIR: "/home/alice/Executor data",
26 PATH: '/home/alice/bin:/opt/Bad "Dir"/bin',
27 },
28 workingDirectory: "/home/alice/Executor data",
29 stdoutPath: "/home/alice/Executor data/logs/daemon.log",
30 stderrPath: "/home/alice/Executor data/logs/daemon.error.log",
31 });
32
33 const unsafeFragments = [
34 'ExecStart="/home/alice/Executor "Beta"/executor"',
35 "Environment=EXECUTOR_DATA_DIR=/home/alice/Executor data",
36 'Environment=PATH=/home/alice/bin:/opt/Bad "Dir"/bin',
37 "WorkingDirectory=/home/alice/Executor data",
38 "StandardOutput=append:/home/alice/Executor data/logs/daemon.log",
39 "StandardError=append:/home/alice/Executor data/logs/daemon.error.log",
40 ].filter((fragment) => unit.includes(fragment));
41
42 expect(unsafeFragments, `unsafe raw fragments in unit:\n${unit}`).toEqual([]);
43 }),
44);
e2e/local/cli-web-transition.test.ts+57−0
@@ -0,0 +1,57 @@
1// Local CLI: `executor web` used to mean "start a foreground server". The
2// first-time CLI setup path is now explicit: install the durable background
3// service first, then use `executor web` to open it. A fresh `web` invocation
4// should guide the user without minting local-server credentials/manifest state
5// or binding ports.
6import { execFile } from "node:child_process";
7import { existsSync, mkdtempSync, rmSync } from "node:fs";
8import { tmpdir } from "node:os";
9import { join } from "node:path";
10import { fileURLToPath } from "node:url";
11import { promisify } from "node:util";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15
16import { scenario } from "../src/scenario";
17
18const execFileAsync = promisify(execFile);
19const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
20
21scenario(
22 "CLI web · a fresh install points users at setup instead of starting a foreground server",
23 { timeout: 120_000 },
24 Effect.promise(async () => {
25 const root = mkdtempSync(join(tmpdir(), "executor-web-transition-"));
26 const dataDir = join(root, "data");
27 try {
28 const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "web"], {
29 cwd: repoRoot,
30 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
31 });
32 const output = `${stdout}\n${stderr}`;
33
34 expect(output, "plain web should not start the old foreground server").not.toContain("Open:");
35 expect(output, "plain web should explain that no service is running").toContain(
36 "Executor is not running.",
37 );
38 expect(output, "plain web should direct first-time users to durable setup").toContain(
39 "install",
40 );
41 expect(
42 output,
43 "plain web should keep the temporary-server escape hatch discoverable",
44 ).toContain("web --foreground");
45 expect(
46 existsSync(join(dataDir, "server-control", "auth.json")),
47 "plain web should not mint a local auth token",
48 ).toBe(false);
49 expect(
50 existsSync(join(dataDir, "server-control", "server.json")),
51 "plain web should not write a foreground server manifest",
52 ).toBe(false);
53 } finally {
54 rmSync(root, { recursive: true, force: true });
55 }
56 }),
57);
e2e/local/local-server.ts+15−12
@@ -1,9 +1,9 @@
1// Shared helper for the `local` e2e project: boot a real `executor web` in a1// Shared helper for the `local` e2e project: boot a real temporary server with
2// recorded terminal, parse its printed one-time `?_token=` URL, and run a body2// `executor web --foreground` in a recorded terminal, parse its printed one-time
3// against it. Each scenario boots its OWN server (own throwaway data dir,3// `?_token=` URL, and run a body against it. Each scenario boots its OWN server
4// `--port 0`) so files can run in parallel without colliding. The terminal4// (own throwaway data dir, `--port 0`) so files can run in parallel without
5// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so5// colliding. The terminal stays up until the body settles, then Ctrl-C gives a
6// the vite child dies and the PTY closes).6// graceful shutdown (so the vite child dies and the PTY closes).
7import { mkdtempSync, rmSync } from "node:fs";7import { mkdtempSync, rmSync } from "node:fs";
8import { tmpdir } from "node:os";8import { tmpdir } from "node:os";
9import { join } from "node:path";9import { join } from "node:path";
@@ -29,10 +29,11 @@ export interface ServerHandle { · 19 unmodified lines
29}29}
30 30
31/**31/**
32 * Boot `executor web` and run `body` against the resulting {@link ServerHandle}.32 * Boot `executor web --foreground` and run `body` against the resulting
33 * Keeps the server up until the body settles, then Ctrl-C for a graceful33 * {@link ServerHandle}. Keeps the server up until the body settles, then Ctrl-C
34 * shutdown. Cleans up the throwaway data dir. The body may drive the browser,34 * for a graceful shutdown. Cleans up the throwaway data dir. The body may drive
35 * a typed API client, an MCP client — anything that needs the live server.35 * the browser, a typed API client, an MCP client — anything that needs the live
36 * server.
36 */37 */
37export const withLocalServer = (38export const withLocalServer = (
38 cli: CliSurface,39 cli: CliSurface,
@@ -54,7 +55,7 @@ export const withLocalServer = ( · 15 unmodified lines
54 yield* Effect.all(55 yield* Effect.all(
55 [56 [
56 cli.session(57 cli.session(
57 ["bun", "run", "dev:cli", "web", "--port", "0"],58 ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"],
58 async (term) => {59 async (term) => {
59 markRecordingStart(runDir, "terminal");60 markRecordingStart(runDir, "terminal");
60 markFocus(runDir, "terminal");61 markFocus(runDir, "terminal");
@@ -64,7 +65,9 @@ export const withLocalServer = ( · 3 unmodified lines
64 );65 );
65 const url = TOKEN_URL.exec(snapshot.text)?.[0];66 const url = TOKEN_URL.exec(snapshot.text)?.[0];
66 if (!url) {67 if (!url) {
67 throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`);68 throw new Error(
69 `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`,
70 );
68 }71 }
69 publishUrl(url);72 publishUrl(url);
70 await bodyDone;73 await bodyDone;
e2e/local/mcp-browser-approve.test.ts+6−5
@@ -5,11 +5,12 @@ · 4 unmodified lines
5// the MCP `resume` tool (auth on the API path), so it never drives the browser5// the MCP `resume` tool (auth on the API path), so it never drives the browser
6// page and could not catch this. This drives the real page in a real browser.6// page and could not catch this. This drives the real page in a real browser.
7//7//
8// Flow: boot `executor web` → create a require_approval policy on a built-in8// Flow: boot `executor web --foreground` → create a require_approval policy on a
9// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser9// built-in tool → an MCP client (bearer) executes that tool with
10// → the server returns a paused `approvalUrl` → open it in the browser (with the10// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it
11// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a11// in the browser (with the `?_token` bootstrap) → click Approve → the MCP
12// negative: the approval endpoint 401s without the bearer.12// `resume` call completes. Plus a negative: the approval endpoint 401s without
13// the bearer.
13import { expect } from "@effect/vitest";14import { expect } from "@effect/vitest";
14import { Effect } from "effect";15import { Effect } from "effect";
15import { HttpApiClient } from "effect/unstable/httpapi";16import { HttpApiClient } from "effect/unstable/httpapi";
e2e/local/vite-dev-routing.test.ts+228−0
@@ -0,0 +1,228 @@
1// Local-only: plain `apps/local` Vite dev must route the same local-only HTTP
2// surfaces as production `executor web`. These routes live outside the typed
3// `/api` HttpApi: `/api/health`, `/api/oauth/await/*`, and browser approval's
4// `/api/mcp-sessions/*`.
5import { type ChildProcess, spawn } from "node:child_process";
6import { randomBytes } from "node:crypto";
7import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8import net from "node:net";
9import { tmpdir } from "node:os";
10import { join } from "node:path";
11import { fileURLToPath } from "node:url";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
18import { scenario } from "../src/scenario";
19import { waitForHttp } from "../setup/boot";
20
21const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22const localAppDir = join(repoRoot, "apps/local");
23
24const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
25const EXECUTE_CODE = `
26const result = await tools.executor.coreTools.policies.list({});
27return JSON.stringify(result);
28`;
29
30const freePort = (): Promise<number> =>
31 new Promise((resolve, reject) => {
32 const srv = net.createServer();
33 srv.on("error", reject);
34 srv.listen(0, "127.0.0.1", () => {
35 const port = (srv.address() as net.AddressInfo).port;
36 srv.close(() => resolve(port));
37 });
38 });
39
40const readToken = async (dataDir: string): Promise<string> => {
41 const path = join(dataDir, "server-control", "auth.json");
42 const deadline = Date.now() + 30_000;
43 while (Date.now() < deadline) {
44 if (existsSync(path)) {
45 const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown };
46 if (typeof parsed.token === "string") return parsed.token;
47 }
48 await new Promise((resolve) => setTimeout(resolve, 100));
49 }
50 throw new Error(`Vite dev did not mint ${path}`);
51};
52
53const stopTree = async (child: ChildProcess): Promise<void> => {
54 if (child.pid === undefined || child.exitCode !== null) return;
55 try {
56 process.kill(-child.pid, "SIGTERM");
57 } catch {
58 child.kill("SIGTERM");
59 }
60 await Promise.race([
61 new Promise((resolve) => child.once("exit", resolve)),
62 new Promise((resolve) => setTimeout(resolve, 5_000)),
63 ]);
64 if (child.exitCode === null) {
65 try {
66 process.kill(-child.pid, "SIGKILL");
67 } catch {
68 child.kill("SIGKILL");
69 }
70 }
71};
72
73interface ViteDev {
74 readonly origin: string;
75 readonly token: string;
76 readonly stop: () => Promise<void>;
77}
78
79const startPlainViteDev = async (): Promise<ViteDev> => {
80 const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-"));
81 const port = await freePort();
82 const origin = `http://127.0.0.1:${port}`;
83 let logs = "";
84 const child = spawn(
85 "bunx",
86 ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
87 {
88 cwd: localAppDir,
89 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) },
90 stdio: ["ignore", "pipe", "pipe"],
91 detached: true,
92 },
93 );
94 child.stdout?.on("data", (chunk: Buffer) => {
95 logs += chunk.toString();
96 });
97 child.stderr?.on("data", (chunk: Buffer) => {
98 logs += chunk.toString();
99 });
100
101 try {
102 await waitForHttp(origin, { timeoutMs: 90_000 });
103 const token = await readToken(dataDir);
104 return {
105 origin,
106 token,
107 stop: async () => {
108 await stopTree(child);
109 rmSync(dataDir, { recursive: true, force: true });
110 },
111 };
112 } catch (error) {
113 await stopTree(child);
114 rmSync(dataDir, { recursive: true, force: true });
115 throw new Error(`plain Vite dev failed to boot:\n${logs}\n${String(error)}`);
116 }
117};
118
119scenario(
120 "Local Vite dev · local-only API routes match production routing",
121 { timeout: 180_000 },
122 Effect.gen(function* () {
123 const vite = yield* Effect.promise(() => startPlainViteDev());
124 yield* Effect.promise(async () => {
125 const failures: string[] = [];
126 let policyId: string | null = null;
127 const auth = { authorization: `Bearer ${vite.token}` };
128
129 try {
130 const health = await fetch(`${vite.origin}/api/health`);
131 const healthText = await health.text();
132 if (health.status !== 200 || healthText !== "ok") {
133 failures.push(`/api/health returned ${health.status} ${JSON.stringify(healthText)}`);
134 }
135
136 const awaited = await fetch(`${vite.origin}/api/oauth/await/session-1`, {
137 headers: auth,
138 });
139 const awaitedText = await awaited.text();
140 if (awaited.status !== 200 || awaitedText !== "null") {
141 failures.push(
142 `/api/oauth/await/session-1 returned ${awaited.status} ${JSON.stringify(awaitedText)}`,
143 );
144 }
145
146 const created = await fetch(`${vite.origin}/api/policies`, {
147 method: "POST",
148 headers: { ...auth, "content-type": "application/json" },
149 body: JSON.stringify({
150 owner: "org",
151 pattern: APPROVAL_TARGET_TOOL,
152 action: "require_approval",
153 }),
154 });
155 if (!created.ok) {
156 failures.push(`/api/policies setup returned ${created.status} ${await created.text()}`);
157 } else {
158 const policy = (await created.json()) as { readonly id?: string };
159 policyId = typeof policy.id === "string" ? policy.id : null;
160
161 const mcp = new Client(
162 { name: `vite-routing-${randomBytes(3).toString("hex")}`, version: "1.0.0" },
163 { capabilities: {} },
164 );
165 const transport = new StreamableHTTPClientTransport(
166 new URL(`${vite.origin}/mcp?elicitation_mode=browser`),
167 { requestInit: { headers: auth } },
168 );
169 await mcp.connect(transport);
170 try {
171 const executed = await mcp.callTool({
172 name: "execute",
173 arguments: { code: EXECUTE_CODE },
174 });
175 const paused = executed.structuredContent as {
176 readonly status?: string;
177 readonly executionId?: string;
178 readonly approvalUrl?: string;
179 };
180 if (
181 paused.status !== "user_approval_required" ||
182 typeof paused.executionId !== "string" ||
183 typeof paused.approvalUrl !== "string"
184 ) {
185 failures.push(
186 `MCP setup did not produce a browser approval: ${JSON.stringify(paused)}`,
187 );
188 } else {
189 const approvalUrl = new URL(paused.approvalUrl);
190 const sessionId = approvalUrl.searchParams.get("mcp_session_id");
191 if (!sessionId) {
192 failures.push(`approval URL had no mcp_session_id: ${paused.approvalUrl}`);
193 } else {
194 const detail = await fetch(
195 `${vite.origin}/api/mcp-sessions/${encodeURIComponent(
196 sessionId,
197 )}/executions/${encodeURIComponent(paused.executionId)}`,
198 { headers: auth },
199 );
200 if (detail.status !== 200) {
201 failures.push(
202 `/api/mcp-sessions paused-detail returned ${detail.status} ${await detail.text()}`,
203 );
204 }
205 }
206 }
207 } finally {
208 await mcp.close();
209 }
210 }
211 } finally {
212 if (policyId) {
213 await fetch(`${vite.origin}/api/policies/${encodeURIComponent(policyId)}`, {
214 method: "DELETE",
215 headers: { ...auth, "content-type": "application/json" },
216 body: JSON.stringify({ owner: "org" }),
217 }).catch(() => {});
218 }
219 await vite.stop();
220 }
221
222 expect(
223 failures,
224 "plain apps/local Vite dev should special-case the same local-only routes as production",
225 ).toEqual([]);
226 });
227 }),
228);
e2e/selfhost/mcp-browser-approval-ownership.test.ts+138−0
@@ -0,0 +1,138 @@
1// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A
2// signed-in user who does not own the MCP session must not be able to read the
3// paused execution or record the human decision for it.
4import { randomBytes } from "node:crypto";
5
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8import { composePluginApi } from "@executor-js/api/server";
9
10import { scenario } from "../src/scenario";
11import { Api, Mcp, Target } from "../src/services";
12import { parseBrowserApproval } from "../src/surfaces/mcp";
13import type { Identity } from "../src/target";
14import { signInSession } from "../targets/selfhost";
15
16const coreApi = composePluginApi([] as const);
17
18const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
19const EXECUTE_CODE = `
20const result = await tools.executor.coreTools.policies.list({});
21return JSON.stringify(result);
22`;
23
24const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
25 const cookie = admin.headers?.cookie;
26 expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
27
28 const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 cookie: cookie!,
33 origin: new URL(baseUrl).origin,
34 },
35 body: JSON.stringify({ role: "member" }),
36 });
37 expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
38 const inviteBody = (await invite.json()) as { readonly code?: string };
39 expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
40
41 const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`;
42 const password = "approval-cross-user-password-123";
43 const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
44 method: "POST",
45 headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
46 body: JSON.stringify({
47 email,
48 password,
49 name: email,
50 inviteCode: inviteBody.code,
51 }),
52 });
53 expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
54
55 const session = await signInSession(baseUrl, { email, password });
56 return {
57 label: email,
58 credentials: { email, password },
59 headers: { cookie: session.cookieHeader },
60 cookies: session.cookies,
61 };
62};
63
64const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL =>
65 new URL(
66 `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(
67 executionId,
68 )}`,
69 baseUrl,
70 );
71
72scenario(
73 "MCP browser approval · another self-host user cannot act on someone else's paused session",
74 { timeout: 180_000 },
75 Effect.gen(function* () {
76 const target = yield* Target;
77 const api = yield* Api;
78 const mcp = yield* Mcp;
79 const owner = yield* target.newIdentity();
80 const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner));
81 const client = yield* api.client(coreApi, owner);
82
83 const policy = yield* client.policies.create({
84 payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
85 });
86
87 yield* Effect.gen(function* () {
88 const session = mcp.session(owner, { elicitationMode: "browser" });
89 yield* session.listTools();
90
91 const paused = yield* session.call("execute", { code: EXECUTE_CODE });
92 const approval = parseBrowserApproval(paused);
93 const approvalUrl = new URL(approval.approvalUrl);
94 const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
95 expect(typeof mcpSessionId, "approval URL is tied to the MCP session").toBe("string");
96
97 const otherHeaders = {
98 cookie: other.headers!.cookie,
99 origin: new URL(target.baseUrl).origin,
100 };
101 const detail = yield* Effect.promise(() =>
102 fetch(approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId), {
103 headers: otherHeaders,
104 }),
105 );
106 expect([403, 404], "a different signed-in user cannot read the paused execution").toContain(
107 detail.status,
108 );
109
110 const decision = yield* Effect.promise(() =>
111 fetch(
112 new URL(
113 `${approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId).pathname}/resume`,
114 target.baseUrl,
115 ),
116 {
117 method: "POST",
118 headers: {
119 ...otherHeaders,
120 "content-type": "application/json",
121 },
122 body: JSON.stringify({ action: "accept" }),
123 },
124 ),
125 );
126 expect(
127 [403, 404],
128 "a different signed-in user cannot approve the paused execution",
129 ).toContain(decision.status);
130 }).pipe(
131 Effect.ensuring(
132 client.policies
133 .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
134 .pipe(Effect.ignore),
135 ),
136 );
137 }),
138);
e2e/setup/desktop-packaged.globalsetup.ts+15−18
@@ -2,12 +2,12 @@ · 1 unmodified line
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/hosts/mcp/src/in-memory-session-store.ts+34−8
@@ -82,13 +82,19 @@ export interface InMemoryMcpSessionStore { · 81 unmodified lines
82 * paused-execution detail the console approval page renders. Returns the82 * paused-execution detail the console approval page renders. Returns the
83 * paused `{ text, structured }` or a 404. Null if the path does not match.83 * paused `{ text, structured }` or a 404. Null if the path does not match.
84 */84 */
85 readonly handlePausedRequest: (request: Request) => Promise<Response | null>;85 readonly handlePausedRequest: (
86 request: Request,
87 principal?: Principal,
88 ) => Promise<Response | null>;
86 /**89 /**
87 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —90 * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` —
88 * record the human's decision and wake the long-polling `resume` tool call.91 * record the human's decision and wake the long-polling `resume` tool call.
89 * Null if the path does not match.92 * Null if the path does not match.
90 */93 */
91 readonly handleApprovalRequest: (request: Request) => Promise<Response | null>;94 readonly handleApprovalRequest: (
95 request: Request,
96 principal?: Principal,
97 ) => Promise<Response | null>;
92 /** Dispose every live session — wire into the host's shutdown (not a seam). */98 /** Dispose every live session — wire into the host's shutdown (not a seam). */
93 readonly close: () => Promise<void>;99 readonly close: () => Promise<void>;
94}100}
@@ -259,6 +265,16 @@ export const makeInMemoryMcpSessionStore = ( · 164 unmodified lines
259 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),265 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
260 };266 };
261 267
268 const ownerAccess = (
269 sessionId: string,
270 principal: Principal | undefined,
271 ): "allowed" | "not-found" | "forbidden" => {
272 const owner = owners.get(sessionId);
273 if (!owner) return "not-found";
274 if (principal && !principalOwns(owner, principal)) return "forbidden";
275 return "allowed";
276 };
277
262 /** Resolve a paused execution from the session that owns it, for HTTP approval. */278 /** Resolve a paused execution from the session that owns it, for HTTP approval. */
263 const pausedFromSession = (279 const pausedFromSession = (
264 sessionId: string,280 sessionId: string,
@@ -274,25 +290,35 @@ export const makeInMemoryMcpSessionStore = ( · 9 unmodified lines
274 );290 );
275 };291 };
276 292
277 const handlePausedRequest = async (request: Request): Promise<Response | null> => {293 const handlePausedRequest = async (
294 request: Request,
295 principal?: Principal,
296 ): Promise<Response | null> => {
278 const match = PAUSED_PATH.exec(new URL(request.url).pathname);297 const match = PAUSED_PATH.exec(new URL(request.url).pathname);
279 if (!match) return null;298 if (!match) return null;
280 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);299 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
281 const paused = await pausedFromSession(300 const sessionId = decodeURIComponent(match[1]!);
282 decodeURIComponent(match[1]!),301 const access = ownerAccess(sessionId, principal);
283 decodeURIComponent(match[2]!),302 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
284 );303 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
304 const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!));
285 if (!paused) return json({ error: "Paused execution not found" }, 404);305 if (!paused) return json({ error: "Paused execution not found" }, 404);
286 return json({ text: paused.text, structured: paused.structured });306 return json({ text: paused.text, structured: paused.structured });
287 };307 };
288 308
289 const handleApprovalRequest = async (request: Request): Promise<Response | null> => {309 const handleApprovalRequest = async (
310 request: Request,
311 principal?: Principal,
312 ): Promise<Response | null> => {
290 const match = RESUME_PATH.exec(new URL(request.url).pathname);313 const match = RESUME_PATH.exec(new URL(request.url).pathname);
291 if (!match) return null;314 if (!match) return null;
292 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);315 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
293 316
294 const sessionId = decodeURIComponent(match[1]!);317 const sessionId = decodeURIComponent(match[1]!);
295 const executionId = decodeURIComponent(match[2]!);318 const executionId = decodeURIComponent(match[2]!);
319 const access = ownerAccess(sessionId, principal);
320 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
321 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);
296 // The session must still hold the paused execution — guards stale ids and322 // The session must still hold the paused execution — guards stale ids and
297 // confirms the execution belongs to this session before recording.323 // confirms the execution belongs to this session before recording.
298 const paused = await pausedFromSession(sessionId, executionId);324 const paused = await pausedFromSession(sessionId, executionId);
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 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t