From 831bfbdd2f40d84d9dd1747bc256d60fd52a6e25 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:50:17 -0700 Subject: [PATCH 1/6] fix supervised and local regressions --- apps/cli/src/daemon.ts | 4 +- apps/cli/src/main.ts | 4 +- apps/cli/src/service.ts | 23 +- apps/desktop/src/main/index.ts | 19 +- apps/desktop/src/main/sidecar.ts | 20 +- apps/desktop/src/sidecar/server.ts | 4 +- apps/host-selfhost/src/mcp/index.ts | 41 +- apps/local/vite.config.ts | 44 ++- .../supervised-regressions.test.ts | 349 ++++++++++++++++++ e2e/local/cli-open-stale-manifest.test.ts | 89 +++++ e2e/local/cli-systemd-unit-escaping.test.ts | 44 +++ e2e/local/vite-dev-routing.test.ts | 228 ++++++++++++ .../mcp-browser-approval-ownership.test.ts | 138 +++++++ .../hosts/mcp/src/in-memory-session-store.ts | 42 ++- 14 files changed, 997 insertions(+), 52 deletions(-) create mode 100644 e2e/desktop-packaged/supervised-regressions.test.ts create mode 100644 e2e/local/cli-open-stale-manifest.test.ts create mode 100644 e2e/local/cli-systemd-unit-escaping.test.ts create mode 100644 e2e/local/vite-dev-routing.test.ts create mode 100644 e2e/selfhost/mcp-browser-approval-ownership.test.ts diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index ec05e95e8..9acb18371 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -89,8 +89,8 @@ export const isExecutorServerReachable = ( // misconfigured base URL can't leak the bearer token to a third-party host. const url = new URL("/api/health", input.baseUrl); const response = await fetch(url, { signal: AbortSignal.timeout(2000) }); - await response.body?.cancel(); - return response.ok; + const body = await response.text(); + return response.ok && body.trim() === "ok"; }).pipe(Effect.catchCause(() => Effect.succeed(false))); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index b0d18c007..077dad3c7 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -2270,8 +2270,8 @@ const openInBrowser = (url: string): Effect.Effect => */ const openCommand = Command.make("open", {}, () => Effect.gen(function* () { - const manifest = yield* readLocalServerManifest(); - if (!manifest || !isPidAlive(manifest.pid)) { + const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + if (!manifest) { console.log("No local Executor server is running."); console.log(`Start one with: ${cliPrefix} web`); return; diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts index 806aec392..0f7e01505 100644 --- a/apps/cli/src/service.ts +++ b/apps/cli/src/service.ts @@ -340,11 +340,24 @@ export interface SystemdUnitOptions { readonly stderrPath: string; } +const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/; + +const systemdQuote = (value: string): string => { + if (SYSTEMD_BARE_VALUE.test(value)) return value; + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + return `"${escaped}"`; +}; + /** Render a systemd --user unit. Pure (snapshot-tested). */ export const generateSystemdUnit = (options: SystemdUnitOptions): string => { - const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); + const execStart = options.execStart.map(systemdQuote).join(" "); const env = Object.entries(options.environment) - .map(([key, value]) => `Environment=${key}=${value}`) + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) .join("\n"); return `[Unit] Description=Executor supervised daemon @@ -354,9 +367,9 @@ After=default.target Type=simple ExecStart=${execStart} ${env} -WorkingDirectory=${options.workingDirectory} -StandardOutput=append:${options.stdoutPath} -StandardError=append:${options.stderrPath} +WorkingDirectory=${systemdQuote(options.workingDirectory)} +StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)} +StandardError=${systemdQuote(`append:${options.stderrPath}`)} Restart=on-failure RestartSec=5s diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1c9a785bd..bf1cf32c4 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -415,12 +415,23 @@ const startWithCurrentSettings = async (): Promise => }; const restartSidecarAndReload = async (): Promise => { - // A supervised daemon isn't ours to restart — just reload the window against - // the same endpoint instead of tearing down a process we don't own. + // A supervised daemon owns its own process lifetime. Re-installing the unit + // rewrites settings such as the configured port, then launchd restarts it. if (connection?.supervisedDaemon) { + await installSupervisedService({ + port: getServerSettings().port, + dataDir: DESKTOP_DATA_DIR, + }); + const next = await waitForSupervisedAttach(30_000); + if (!next) { + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call + throw new Error("Supervised daemon failed to restart — see Settings"); + } + connection = next; + installBearerAuthHeader(next.baseUrl, next.authToken); const window = liveMainWindow(); - if (window) await window.loadURL(connection.baseUrl); - return toDesktopServerConnection(connection); + if (window) await window.loadURL(next.baseUrl); + return toDesktopServerConnection(next); } if (connection) { await stopConnection(connection); diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 5354277bc..37eafb57e 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -408,20 +408,18 @@ export async function startSidecar(options: StartOptions = {}): Promise => { +/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */ +const isDaemonReachable = async (origin: string): Promise => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1500); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal try { - const headers: Record = {}; - if (authToken) headers.Authorization = `Bearer ${authToken}`; - await fetch(origin, { signal: controller.signal, headers, redirect: "manual" }); - return true; + const response = await fetch(new URL("/api/health", origin), { + signal: controller.signal, + redirect: "manual", + }); + const body = await response.text(); + return response.ok && body.trim() === "ok"; } catch { return false; } finally { @@ -452,7 +450,7 @@ export async function attachToSupervisedDaemon(): Promise { executablePath: process.execPath || null, }, }), + { mode: 0o600 }, ); + chmodSync(manifestPath, 0o600); }; const removeOwnManifest = () => { diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index b45eba388..603b2c376 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -1,7 +1,12 @@ import { Effect, Layer } from "effect"; import { IdentityProvider } from "@executor-js/api/server"; -import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; +import type { + McpAuthProvider, + McpErrorReporter, + McpSessionStore, + Principal, +} from "@executor-js/host-mcp"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import type { SelfHostDbHandle } from "../db/self-host-db"; @@ -64,12 +69,35 @@ export interface SelfHostMcpSeams { const jsonResponse = (value: unknown, status: number): Response => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +const parseRoles = (role: string | null | undefined): ReadonlyArray => + (role ?? "user") + .split(",") + .map((r) => r.trim()) + .filter((r) => r.length > 0); + +type BetterAuthSession = NonNullable< + Awaited> +>; + +const principalFromSession = ( + resolved: BetterAuthSession, + betterAuth: BetterAuthHandle, +): Principal => ({ + accountId: resolved.user.id, + organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId, + organizationName: betterAuth.organizationName, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles: parseRoles(resolved.user.role ?? null), +}); + /** * Gate the browser-approval endpoints behind a valid Better Auth session (the * console page calls them with the user's cookie), then delegate to the - * in-process store's paused/resume handlers. Single-tenant: any authenticated - * user of the one org may act on a session it still holds — the store confirms - * the execution belongs to the addressed session before recording. + * in-process store's paused/resume handlers with the resolved principal so the + * store can enforce MCP session ownership before exposing or recording a + * browser-approval decision. */ const makeApprovalHandler = ( @@ -85,10 +113,11 @@ const makeApprovalHandler = }).pipe(Effect.orElseSucceed(() => null)), ); if (!session) return jsonResponse({ error: "Unauthorized" }, 401); + const principal = principalFromSession(session, betterAuth); return ( - (await store.handlePausedRequest(request)) ?? - (await store.handleApprovalRequest(request)) ?? + (await store.handlePausedRequest(request, principal)) ?? + (await store.handleApprovalRequest(request, principal)) ?? jsonResponse({ error: "Not found" }, 404) ); }; diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index 828146c07..22463be74 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite"; import appPlugin from "@executor-js/app/vite"; import { loadOrMintLocalAuthToken } from "./src/auth"; +import { consumeOAuthResult } from "./src/oauth-result-store"; import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json @@ -114,20 +115,37 @@ function executorApiPlugin(): Plugin { if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); } - // Strip /api prefix for Effect handlers - const url = isApi ? rawUrl.slice("/api".length) || "/" : rawUrl; - const hasBody = req.method !== "GET" && req.method !== "HEAD"; - const webRequest = new Request(new URL(url, origin), { - method: req.method, - headers, - body: hasBody ? Readable.toWeb(req) : undefined, - duplex: hasBody ? "half" : undefined, - } as RequestInit); - - const response = isMcp - ? await handlers.mcp.handleRequest(webRequest) - : await handlers.api.handler(webRequest); + const webRequest = (url: string): Request => + new Request(new URL(url, origin), { + method: req.method, + headers, + body: hasBody ? Readable.toWeb(req) : undefined, + duplex: hasBody ? "half" : undefined, + } as RequestInit); + + let response: Response; + if (isMcp) { + response = await handlers.mcp.handleRequest(webRequest(rawUrl)); + } else if (pathOnly === "/api/health" && req.method === "GET") { + response = new Response("ok", { headers: { "content-type": "text/plain" } }); + } else if (pathOnly.startsWith("/api/mcp-sessions/")) { + const handler = + req.method === "GET" + ? handlers.mcp.handlePausedRequest + : handlers.mcp.handleApprovalRequest; + response = await handler(webRequest(rawUrl)); + } else { + const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); + if (awaitMatch && req.method === "GET") { + response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), { + headers: { "content-type": "application/json" }, + }); + } else { + // Strip /api prefix for Effect handlers. + response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/")); + } + } res.statusCode = response.status; response.headers.forEach((v, k) => res.setHeader(k, v)); diff --git a/e2e/desktop-packaged/supervised-regressions.test.ts b/e2e/desktop-packaged/supervised-regressions.test.ts new file mode 100644 index 000000000..88bbc619c --- /dev/null +++ b/e2e/desktop-packaged/supervised-regressions.test.ts @@ -0,0 +1,349 @@ +// Packaged desktop supervised-daemon regressions. These run against the real +// electron-builder bundle and its compiled sidecar because the supervised attach +// path is production-only (`app.isPackaged`). +import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage } from "node:http"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { _electron, type ElectronApplication } from "playwright"; +import { + normalizeExecutorServerConnection, + serializeExecutorLocalServerManifest, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; +import { waitForHttp } from "../setup/boot"; + +interface PackagedExecutorBridge { + readonly getSettings: () => Promise<{ readonly port: number }>; + readonly updateSettings: (patch: { readonly port: number }) => Promise; + readonly restartServer: () => Promise; + readonly getServerConnection: () => Promise<{ readonly origin: string } | null>; +} + +declare global { + interface Window { + readonly executor: PackagedExecutorBridge; + } +} + +const appExe = process.env.E2E_DESKTOP_APP_EXE; +const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; +const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; + +const guiAvailable = (): boolean => { + if (process.platform === "darwin") { + try { + return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua"; + } catch { + return false; + } + } + if (process.platform === "linux") + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + return true; +}; + +const packagedSingleInstanceAvailable = (): boolean => { + if (process.platform !== "darwin" || !appExe) return true; + try { + const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], { + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); + return !lines.some((line) => !line.includes(appExe)); + } catch { + return true; + } +}; + +const requireBundle = (): { readonly app: string; readonly sidecar: string } => { + if (!appExe || !sidecarBin) { + throw new Error( + "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", + ); + } + return { app: appExe, sidecar: sidecarBin }; +}; + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); + +interface DaemonStart { + readonly child: ChildProcess; + readonly ready: boolean; + readonly stderr: string; +} + +const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => + new Promise((resolve) => { + const { sidecar } = requireBundle(); + const child = spawn(sidecar, [], { env, stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + let settled = false; + const settle = (ready: boolean) => { + if (settled) return; + settled = true; + resolve({ child, ready, stderr }); + }; + const timer = setTimeout(() => settle(false), 60_000); + child.stdout.on("data", (chunk: Buffer) => { + if (chunk.toString().includes("EXECUTOR_READY:")) { + clearTimeout(timer); + settle(true); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("exit", () => { + clearTimeout(timer); + settle(false); + }); + }); + +const closeWithVideo = async ( + app: ElectronApplication | undefined, + runDir: string, + videoTmp: string, +) => { + const page = app?.windows()[0]; + const video = page?.video(); + await app?.close().catch(() => {}); + const recordedPath = await video?.path().catch(() => undefined); + if (recordedPath) { + await promisify(execFile)("ffmpeg", [ + "-y", + "-i", + recordedPath, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "26", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + join(runDir, "session.mp4"), + ]).catch(() => {}); + } + rmSync(videoTmp, { recursive: true, force: true }); +}; + +scenario( + "Desktop packaged supervised daemon · server manifest is owner-only", + { timeout: 180_000 }, + Effect.promise(async () => { + requireBundle(); + const home = mkdtempSync(join(tmpdir(), "executor-pkg-manifest-mode-")); + const dataDir = join(home, ".executor"); + const manifestPath = join(dataDir, "server-control", "server.json"); + const port = await freePort(); + let daemon: ChildProcess | undefined; + const previousUmask = process.umask(0o022); + try { + const started = await startSupervisedDaemon({ + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_PORT: String(port), + EXECUTOR_HOST: "127.0.0.1", + EXECUTOR_AUTH_TOKEN: "manifest-mode-token", + EXECUTOR_CLIENT_DIR: clientDir, + }); + daemon = started.child; + expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe( + true, + ); + await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); + + const mode = statSync(manifestPath).mode & 0o777; + expect( + mode.toString(8).padStart(3, "0"), + "server.json embeds the bearer and must be owner read/write only", + ).toBe("600"); + } finally { + process.umask(previousUmask); + daemon?.kill("SIGTERM"); + rmSync(home, { recursive: true, force: true }); + } + }), +); + +if (!guiAvailable() || !packagedSingleInstanceAvailable()) { + it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {}); +} else { + scenario( + "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", + { timeout: 240_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => runStaleManifestProbe(runDir)); + }), + ); + + scenario( + "Desktop packaged supervised settings · changing the port moves the active daemon", + { timeout: 300_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => runSupervisedPortSetting(runDir)); + }), + ); +} + +const launchPackaged = (home: string, videoTmp: string): Promise => { + const { app } = requireBundle(); + return _electron.launch({ + executablePath: app, + env: { ...process.env, HOME: home }, + recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + timeout: 120_000, + }); +}; + +const runStaleManifestProbe = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); + const dataDir = join(home, ".executor"); + const controlDir = join(dataDir, "server-control"); + const videoTmp = join(runDir, ".video-tmp"); + const token = "stale-manifest-leaked-token"; + const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; + let resolveFirst!: () => void; + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve; + }); + const server = createServer((req: IncomingMessage, res) => { + requests.push({ + url: req.url ?? "/", + authorization: req.headers.authorization ?? null, + }); + resolveFirst(); + res.writeHead(200, { "content-type": "text/html" }); + res.end("fake daemonfake daemon"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + let app: ElectronApplication | undefined; + + try { + mkdirSync(controlDir, { recursive: true }); + writeFileSync( + join(controlDir, "server.json"), + serializeExecutorLocalServerManifest({ + version: 1, + kind: "cli-daemon", + pid: process.pid, + startedAt: new Date().toISOString(), + dataDir, + scopeDir: dataDir, + connection: normalizeExecutorServerConnection({ + origin: `http://127.0.0.1:${port}`, + displayName: "Stale daemon", + auth: { kind: "bearer", token }, + }), + owner: { client: "cli", version: null, executablePath: null }, + }), + { mode: 0o600 }, + ); + + app = await launchPackaged(home, videoTmp); + const probed = await Promise.race([ + firstRequest.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 60_000)), + ]); + expect(probed, "packaged app probed the stale manifest endpoint").toBe(true); + + expect( + requests[0]?.authorization ?? null, + "the stale-manifest reachability probe must not disclose the saved bearer", + ).toBeNull(); + } finally { + await closeWithVideo(app, runDir, videoTmp); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(home, { recursive: true, force: true }); + } +}; + +const runSupervisedPortSetting = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-")); + const dataDir = join(home, ".executor"); + const videoTmp = join(runDir, ".video-tmp"); + const oldPort = await freePort(); + const newPort = await freePort(); + let daemon: ChildProcess | undefined; + let app: ElectronApplication | undefined; + + try { + const started = await startSupervisedDaemon({ + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_PORT: String(oldPort), + EXECUTOR_HOST: "127.0.0.1", + EXECUTOR_AUTH_TOKEN: "port-setting-token", + EXECUTOR_CLIENT_DIR: clientDir, + }); + daemon = started.child; + expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); + await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 }); + + app = await launchPackaged(home, videoTmp); + const page = await app.firstWindow({ timeout: 120_000 }); + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + + const before = await page.evaluate(async () => { + return window.executor.getServerConnection(); + }); + expect(new URL(before!.origin).port, "test starts attached to the original port").toBe( + String(oldPort), + ); + + await page.evaluate(async (port) => { + await window.executor.updateSettings({ port }); + }, newPort); + + await page + .evaluate(async () => { + await window.executor.restartServer(); + }) + .catch(() => undefined); + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + + const after = await page.evaluate(async () => { + return { + settings: await window.executor.getSettings(), + connection: await window.executor.getServerConnection(), + }; + }); + + expect(after.settings.port, "the setting was persisted").toBe(newPort); + expect( + new URL(after.connection!.origin).port, + "after restart, the active supervised daemon should be serving on the saved port", + ).toBe(String(newPort)); + } finally { + await closeWithVideo(app, runDir, videoTmp); + daemon?.kill("SIGTERM"); + rmSync(home, { recursive: true, force: true }); + } +}; diff --git a/e2e/local/cli-open-stale-manifest.test.ts b/e2e/local/cli-open-stale-manifest.test.ts new file mode 100644 index 000000000..a7d6c7795 --- /dev/null +++ b/e2e/local/cli-open-stale-manifest.test.ts @@ -0,0 +1,89 @@ +// Local CLI: `executor open` must not trust a live pid in server.json until it +// has proven the recorded endpoint is actually the running Executor server. +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { + normalizeExecutorServerConnection, + serializeExecutorLocalServerManifest, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; + +const execFileAsync = promisify(execFile); +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); + +const openerName = (): string => + process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; + +scenario( + "CLI open · a stale manifest cannot print or open the saved bearer URL", + { timeout: 120_000 }, + Effect.promise(async () => { + const dataDir = mkdtempSync(join(tmpdir(), "executor-open-stale-")); + const openerDir = mkdtempSync(join(tmpdir(), "executor-open-shim-")); + try { + const port = await freePort(); + const token = `stale-token-${randomBytes(4).toString("hex")}`; + mkdirSync(join(dataDir, "server-control"), { recursive: true }); + writeFileSync( + join(dataDir, "server-control", "server.json"), + serializeExecutorLocalServerManifest({ + version: 1, + kind: "foreground", + pid: process.pid, + startedAt: new Date().toISOString(), + dataDir, + scopeDir: dataDir, + connection: normalizeExecutorServerConnection({ + origin: `http://127.0.0.1:${port}`, + displayName: "Stale test server", + auth: { kind: "bearer", token }, + }), + owner: { client: "cli", version: null, executablePath: null }, + }), + { mode: 0o600 }, + ); + + const openerPath = join(openerDir, openerName()); + writeFileSync(openerPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + chmodSync(openerPath, 0o755); + + const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "open"], { + cwd: repoRoot, + env: { + ...process.env, + EXECUTOR_DATA_DIR: dataDir, + PATH: `${openerDir}${delimiter}${process.env.PATH ?? ""}`, + }, + }); + const output = `${stdout}\n${stderr}`; + expect(output, "stale endpoint should be rejected before printing the token URL").toContain( + "No local Executor server is running.", + ); + expect(output, "the stale bearer must not be printed").not.toContain(token); + expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`); + } finally { + rmSync(openerDir, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + } + }), +); diff --git a/e2e/local/cli-systemd-unit-escaping.test.ts b/e2e/local/cli-systemd-unit-escaping.test.ts new file mode 100644 index 000000000..ead36d92d --- /dev/null +++ b/e2e/local/cli-systemd-unit-escaping.test.ts @@ -0,0 +1,44 @@ +// Artifact-level e2e for the Linux service install surface: the emitted +// systemd unit must not contain raw, unescaped paths or environment values +// whose spaces/quotes change systemd tokenization at boot. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { generateSystemdUnit } from "../../apps/cli/src/service"; +import { scenario } from "../src/scenario"; + +scenario( + "CLI service · generated systemd units escape paths and environment values", + {}, + Effect.sync(() => { + const unit = generateSystemdUnit({ + execStart: [ + '/home/alice/Executor "Beta"/executor', + "daemon", + "run", + "--foreground", + "--port", + "4789", + ], + environment: { + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: "/home/alice/Executor data", + PATH: '/home/alice/bin:/opt/Bad "Dir"/bin', + }, + workingDirectory: "/home/alice/Executor data", + stdoutPath: "/home/alice/Executor data/logs/daemon.log", + stderrPath: "/home/alice/Executor data/logs/daemon.error.log", + }); + + const unsafeFragments = [ + 'ExecStart="/home/alice/Executor "Beta"/executor"', + "Environment=EXECUTOR_DATA_DIR=/home/alice/Executor data", + 'Environment=PATH=/home/alice/bin:/opt/Bad "Dir"/bin', + "WorkingDirectory=/home/alice/Executor data", + "StandardOutput=append:/home/alice/Executor data/logs/daemon.log", + "StandardError=append:/home/alice/Executor data/logs/daemon.error.log", + ].filter((fragment) => unit.includes(fragment)); + + expect(unsafeFragments, `unsafe raw fragments in unit:\n${unit}`).toEqual([]); + }), +); diff --git a/e2e/local/vite-dev-routing.test.ts b/e2e/local/vite-dev-routing.test.ts new file mode 100644 index 000000000..287eb3f8c --- /dev/null +++ b/e2e/local/vite-dev-routing.test.ts @@ -0,0 +1,228 @@ +// Local-only: plain `apps/local` Vite dev must route the same local-only HTTP +// surfaces as production `executor web`. These routes live outside the typed +// `/api` HttpApi: `/api/health`, `/api/oauth/await/*`, and browser approval's +// `/api/mcp-sessions/*`. +import { type ChildProcess, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { scenario } from "../src/scenario"; +import { waitForHttp } from "../setup/boot"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const localAppDir = join(repoRoot, "apps/local"); + +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); + +const readToken = async (dataDir: string): Promise => { + const path = join(dataDir, "server-control", "auth.json"); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (existsSync(path)) { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown }; + if (typeof parsed.token === "string") return parsed.token; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Vite dev did not mint ${path}`); +}; + +const stopTree = async (child: ChildProcess): Promise => { + if (child.pid === undefined || child.exitCode !== null) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (child.exitCode === null) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } +}; + +interface ViteDev { + readonly origin: string; + readonly token: string; + readonly stop: () => Promise; +} + +const startPlainViteDev = async (): Promise => { + const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-")); + const port = await freePort(); + const origin = `http://127.0.0.1:${port}`; + let logs = ""; + const child = spawn( + "bunx", + ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], + { + cwd: localAppDir, + env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }, + ); + child.stdout?.on("data", (chunk: Buffer) => { + logs += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + logs += chunk.toString(); + }); + + try { + await waitForHttp(origin, { timeoutMs: 90_000 }); + const token = await readToken(dataDir); + return { + origin, + token, + stop: async () => { + await stopTree(child); + rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await stopTree(child); + rmSync(dataDir, { recursive: true, force: true }); + throw new Error(`plain Vite dev failed to boot:\n${logs}\n${String(error)}`); + } +}; + +scenario( + "Local Vite dev · local-only API routes match production routing", + { timeout: 180_000 }, + Effect.gen(function* () { + const vite = yield* Effect.promise(() => startPlainViteDev()); + yield* Effect.promise(async () => { + const failures: string[] = []; + let policyId: string | null = null; + const auth = { authorization: `Bearer ${vite.token}` }; + + try { + const health = await fetch(`${vite.origin}/api/health`); + const healthText = await health.text(); + if (health.status !== 200 || healthText !== "ok") { + failures.push(`/api/health returned ${health.status} ${JSON.stringify(healthText)}`); + } + + const awaited = await fetch(`${vite.origin}/api/oauth/await/session-1`, { + headers: auth, + }); + const awaitedText = await awaited.text(); + if (awaited.status !== 200 || awaitedText !== "null") { + failures.push( + `/api/oauth/await/session-1 returned ${awaited.status} ${JSON.stringify(awaitedText)}`, + ); + } + + const created = await fetch(`${vite.origin}/api/policies`, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + pattern: APPROVAL_TARGET_TOOL, + action: "require_approval", + }), + }); + if (!created.ok) { + failures.push(`/api/policies setup returned ${created.status} ${await created.text()}`); + } else { + const policy = (await created.json()) as { readonly id?: string }; + policyId = typeof policy.id === "string" ? policy.id : null; + + const mcp = new Client( + { name: `vite-routing-${randomBytes(3).toString("hex")}`, version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport( + new URL(`${vite.origin}/mcp?elicitation_mode=browser`), + { requestInit: { headers: auth } }, + ); + await mcp.connect(transport); + try { + const executed = await mcp.callTool({ + name: "execute", + arguments: { code: EXECUTE_CODE }, + }); + const paused = executed.structuredContent as { + readonly status?: string; + readonly executionId?: string; + readonly approvalUrl?: string; + }; + if ( + paused.status !== "user_approval_required" || + typeof paused.executionId !== "string" || + typeof paused.approvalUrl !== "string" + ) { + failures.push( + `MCP setup did not produce a browser approval: ${JSON.stringify(paused)}`, + ); + } else { + const approvalUrl = new URL(paused.approvalUrl); + const sessionId = approvalUrl.searchParams.get("mcp_session_id"); + if (!sessionId) { + failures.push(`approval URL had no mcp_session_id: ${paused.approvalUrl}`); + } else { + const detail = await fetch( + `${vite.origin}/api/mcp-sessions/${encodeURIComponent( + sessionId, + )}/executions/${encodeURIComponent(paused.executionId)}`, + { headers: auth }, + ); + if (detail.status !== 200) { + failures.push( + `/api/mcp-sessions paused-detail returned ${detail.status} ${await detail.text()}`, + ); + } + } + } + } finally { + await mcp.close(); + } + } + } finally { + if (policyId) { + await fetch(`${vite.origin}/api/policies/${encodeURIComponent(policyId)}`, { + method: "DELETE", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ owner: "org" }), + }).catch(() => {}); + } + await vite.stop(); + } + + expect( + failures, + "plain apps/local Vite dev should special-case the same local-only routes as production", + ).toEqual([]); + }); + }), +); diff --git a/e2e/selfhost/mcp-browser-approval-ownership.test.ts b/e2e/selfhost/mcp-browser-approval-ownership.test.ts new file mode 100644 index 000000000..8924ff6e5 --- /dev/null +++ b/e2e/selfhost/mcp-browser-approval-ownership.test.ts @@ -0,0 +1,138 @@ +// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A +// signed-in user who does not own the MCP session must not be able to read the +// paused execution or record the human decision for it. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { parseBrowserApproval } from "../src/surfaces/mcp"; +import type { Identity } from "../src/target"; +import { signInSession } from "../targets/selfhost"; + +const coreApi = composePluginApi([] as const); + +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise => { + const cookie = admin.headers?.cookie; + expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string"); + + const invite = await fetch(new URL("/api/admin/invites", baseUrl), { + method: "POST", + headers: { + "content-type": "application/json", + cookie: cookie!, + origin: new URL(baseUrl).origin, + }, + body: JSON.stringify({ role: "member" }), + }); + expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200); + const inviteBody = (await invite.json()) as { readonly code?: string }; + expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string"); + + const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`; + const password = "approval-cross-user-password-123"; + const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, + body: JSON.stringify({ + email, + password, + name: email, + inviteCode: inviteBody.code, + }), + }); + expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200); + + const session = await signInSession(baseUrl, { email, password }); + return { + label: email, + credentials: { email, password }, + headers: { cookie: session.cookieHeader }, + cookies: session.cookies, + }; +}; + +const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL => + new URL( + `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent( + executionId, + )}`, + baseUrl, + ); + +scenario( + "MCP browser approval · another self-host user cannot act on someone else's paused session", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const mcp = yield* Mcp; + const owner = yield* target.newIdentity(); + const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner)); + const client = yield* api.client(coreApi, owner); + + const policy = yield* client.policies.create({ + payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = mcp.session(owner, { elicitationMode: "browser" }); + yield* session.listTools(); + + const paused = yield* session.call("execute", { code: EXECUTE_CODE }); + const approval = parseBrowserApproval(paused); + const approvalUrl = new URL(approval.approvalUrl); + const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id"); + expect(typeof mcpSessionId, "approval URL is tied to the MCP session").toBe("string"); + + const otherHeaders = { + cookie: other.headers!.cookie, + origin: new URL(target.baseUrl).origin, + }; + const detail = yield* Effect.promise(() => + fetch(approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId), { + headers: otherHeaders, + }), + ); + expect([403, 404], "a different signed-in user cannot read the paused execution").toContain( + detail.status, + ); + + const decision = yield* Effect.promise(() => + fetch( + new URL( + `${approvalEndpoint(target.baseUrl, mcpSessionId!, approval.executionId).pathname}/resume`, + target.baseUrl, + ), + { + method: "POST", + headers: { + ...otherHeaders, + "content-type": "application/json", + }, + body: JSON.stringify({ action: "accept" }), + }, + ), + ); + expect( + [403, 404], + "a different signed-in user cannot approve the paused execution", + ).toContain(decision.status); + }).pipe( + Effect.ensuring( + client.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 393c829b0..34c7752dd 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -82,13 +82,19 @@ export interface InMemoryMcpSessionStore { * paused-execution detail the console approval page renders. Returns the * paused `{ text, structured }` or a 404. Null if the path does not match. */ - readonly handlePausedRequest: (request: Request) => Promise; + readonly handlePausedRequest: ( + request: Request, + principal?: Principal, + ) => Promise; /** * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — * record the human's decision and wake the long-polling `resume` tool call. * Null if the path does not match. */ - readonly handleApprovalRequest: (request: Request) => Promise; + readonly handleApprovalRequest: ( + request: Request, + principal?: Principal, + ) => Promise; /** Dispose every live session — wire into the host's shutdown (not a seam). */ readonly close: () => Promise; } @@ -246,6 +252,16 @@ export const makeInMemoryMcpSessionStore = ( Effect.promise(() => dispose(sessionId, { transport: true, server: true })), }; + const ownerAccess = ( + sessionId: string, + principal: Principal | undefined, + ): "allowed" | "not-found" | "forbidden" => { + const owner = owners.get(sessionId); + if (!owner) return "not-found"; + if (principal && !principalOwns(owner, principal)) return "forbidden"; + return "allowed"; + }; + /** Resolve a paused execution from the session that owns it, for HTTP approval. */ const pausedFromSession = ( sessionId: string, @@ -261,25 +277,35 @@ export const makeInMemoryMcpSessionStore = ( ); }; - const handlePausedRequest = async (request: Request): Promise => { + const handlePausedRequest = async ( + request: Request, + principal?: Principal, + ): Promise => { const match = PAUSED_PATH.exec(new URL(request.url).pathname); if (!match) return null; if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); - const paused = await pausedFromSession( - decodeURIComponent(match[1]!), - decodeURIComponent(match[2]!), - ); + const sessionId = decodeURIComponent(match[1]!); + const access = ownerAccess(sessionId, principal); + if (access === "forbidden") return json({ error: "Forbidden" }, 403); + if (access === "not-found") return json({ error: "Paused execution not found" }, 404); + const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!)); if (!paused) return json({ error: "Paused execution not found" }, 404); return json({ text: paused.text, structured: paused.structured }); }; - const handleApprovalRequest = async (request: Request): Promise => { + const handleApprovalRequest = async ( + request: Request, + principal?: Principal, + ): Promise => { const match = RESUME_PATH.exec(new URL(request.url).pathname); if (!match) return null; if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); const sessionId = decodeURIComponent(match[1]!); const executionId = decodeURIComponent(match[2]!); + const access = ownerAccess(sessionId, principal); + if (access === "forbidden") return json({ error: "Forbidden" }, 403); + if (access === "not-found") return json({ error: "Paused execution not found" }, 404); // The session must still hold the paused execution — guards stale ids and // confirms the execution belongs to this session before recording. const paused = await pausedFromSession(sessionId, executionId); From 30ecd42665ec183a303e752ee854791cce61334e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:33:55 -0700 Subject: [PATCH 2/6] Update CLI web setup flow --- README.md | 9 +- apps/cli/src/main.ts | 217 +++++++++++++--------- e2e/local/auth.test.ts | 18 +- e2e/local/cli-open-stale-manifest.test.ts | 6 +- e2e/local/cli-web-transition.test.ts | 57 ++++++ e2e/local/local-server.ts | 27 +-- e2e/local/mcp-browser-approve.test.ts | 11 +- 7 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 e2e/local/cli-web-transition.test.ts diff --git a/README.md b/README.md index 104e2f397..5eabe62c6 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e ```bash npm install -g executor +executor install executor web ``` -This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools. +This installs the local background service and opens the web UI. From there, add your first source and start using tools. ### Use as an MCP server @@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h ### Via the web UI -Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth. +Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth. ### Via the CLI @@ -119,7 +120,9 @@ executor resume --execution-id exec_123 ## CLI reference ```bash -executor web # start runtime + web UI +executor install # install/start the durable background service +executor web # open the running web UI +executor web --foreground # start a temporary foreground runtime + web UI executor daemon run # start persistent local daemon in background executor daemon status # show daemon status executor daemon stop # stop daemon diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 077dad3c7..a02168c55 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( const webCommand = Command.make( "web", { - port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)), + foreground: Options.boolean("foreground") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Run a temporary web server in this terminal. By default, web opens the installed background service.", + ), + ), + port: Options.integer("port") + .pipe(Options.withDefault(DEFAULT_PORT)) + .pipe(Options.withDescription("Port for the temporary --foreground server.")), hostname: Options.string("hostname") .pipe(Options.withDefault("127.0.0.1")) - .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")), + .pipe( + Options.withDescription( + "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.", + ), + ), allowedHost: Options.string("allowed-host") .pipe(Options.atLeast(0)) .pipe( Options.withDescription( - "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.", + "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.", ), ), authToken: Options.string("auth-token") .pipe(Options.optional) .pipe( Options.withDescription( - "Override the bearer token. Defaults to the stable token in auth.json.", + "For --foreground, override the bearer token. Defaults to the stable token in auth.json.", ), ), scope, }, - ({ port, scope, hostname, allowedHost, authToken }) => + ({ foreground, port, scope, hostname, allowedHost, authToken }) => Effect.gen(function* () { + if (!foreground) { + yield* openRunningLocalWebApp(); + return; + } applyScope(scope); yield* runForegroundSession({ port, @@ -1946,7 +1963,7 @@ const webCommand = Command.make( authToken: Option.getOrUndefined(authToken), }); }), -).pipe(Command.withDescription("Start a foreground web session")); +).pipe(Command.withDescription("Open the Executor web UI")); const daemonRunCommand = Command.make( "run", @@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; -const serviceInstallCommand = Command.make( - "install", - { - port: Options.integer("port") - .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) - .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")), - }, - ({ port }) => - Effect.gen(function* () { - if (isDevMode) { - return yield* Effect.fail( - new Error( - [ - "`service install` requires the compiled `executor` binary so the OS can run it directly.", - `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`, - ].join("\n"), - ), - ); - } - - const backend = getServiceBackend(); - if (!backend.automated) { - // Unsupported platforms surface their manual steps via the install error. - yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); - return; - } +const servicePortOption = () => + Options.integer("port") + .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) + .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")); - // Don't fight an already-running local server against the same data dir - // (a desktop sidecar, a foreground `executor web`, or an existing daemon). - const active = yield* readActiveLocalServerManifest(); - if (active) { - const status = yield* backend.status(); - if (status.registered && status.running && active.kind === "cli-daemon") { - console.log( - `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`, - ); - return; - } - return yield* Effect.fail( - new Error( - [ - `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, - `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`, - ].join("\n"), - ), - ); - } +const installService = (port: number, commandName: string) => + Effect.gen(function* () { + const command = `${cliPrefix} ${commandName}`; + if (isDevMode) { + return yield* Effect.fail( + new Error( + [ + `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`, + `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`, + ].join("\n"), + ), + ); + } - // The unit carries no secret: the supervised daemon mints/loads its bearer - // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read - // the same file — so reachability is the credential-free /api/health probe. + const backend = getServiceBackend(); + if (!backend.automated) { + // Unsupported platforms surface their manual steps via the install error. yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + return; + } - const origin = supervisedServiceOrigin(port); - const reachable = yield* waitForReachable({ - check: isServerReachable(origin), - timeoutMs: DAEMON_BOOT_TIMEOUT_MS, - intervalMs: DAEMON_BOOT_POLL_MS, - }); - if (!reachable) { - return yield* Effect.fail( - new Error( - [ - `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`, - `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, - ].join("\n"), - ), + // Don't fight an already-running local server against the same data dir + // (a desktop sidecar, a foreground `executor web`, or an existing daemon). + const active = yield* readActiveLocalServerManifest(); + if (active) { + const status = yield* backend.status(); + if (status.registered && status.running && active.kind === "cli-daemon") { + console.log( + `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`, ); + return; } + return yield* Effect.fail( + new Error( + [ + `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, + `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run \`${command}\`.`, + ].join("\n"), + ), + ); + } - console.log(`Executor is now running as a background service at ${origin}.`); - console.log("It keeps serving after you quit the app and restarts on login."); - console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`); - }), + // The unit carries no secret: the supervised daemon mints/loads its bearer + // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read + // the same file — so reachability is the credential-free /api/health probe. + yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + + const origin = supervisedServiceOrigin(port); + const reachable = yield* waitForReachable({ + check: isServerReachable(origin), + timeoutMs: DAEMON_BOOT_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!reachable) { + return yield* Effect.fail( + new Error( + [ + `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`, + `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, + ].join("\n"), + ), + ); + } + + console.log(`Executor is now running as a background service at ${origin}.`); + console.log("It keeps serving after you quit the app and restarts on login."); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); + }); + +const serviceInstallCommand = Command.make( + "install", + { + port: servicePortOption(), + }, + ({ port }) => installService(port, "service install"), ).pipe( Command.withDescription("Install and start Executor as an OS-supervised background service"), ); @@ -2240,6 +2263,16 @@ const serviceCommand = Command.make("service").pipe( Command.withDescription("Manage the OS-supervised background service"), ); +const installCommand = Command.make( + "install", + { + port: servicePortOption(), + }, + ({ port }) => installService(port, "install"), +).pipe( + Command.withDescription("Install and start Executor as an OS-supervised background service"), +); + // --------------------------------------------------------------------------- // Root command // --------------------------------------------------------------------------- @@ -2263,17 +2296,28 @@ const openInBrowser = (url: string): Effect.Effect => execFile(cmd, [...args], () => {}); }); -/** - * `executor open` — the friendly way back in. Reads the running local server's - * manifest and opens the browser straight to its `?_token=` URL, so the user - * never has to copy a bearer token out of a terminal or auth.json by hand. - */ -const openCommand = Command.make("open", {}, () => +const printNoRunningLocalWebApp = (): void => { + console.log("Executor is not running."); + console.log(""); + console.log("Install and start the background service:"); + console.log(` ${cliPrefix} install`); + console.log(""); + console.log("Then open the web UI:"); + console.log(` ${cliPrefix} web`); + console.log(""); + console.log("For a temporary foreground server:"); + console.log(` ${cliPrefix} web --foreground`); +}; + +const openRunningLocalWebApp = (): Effect.Effect< + void, + never, + FileSystem.FileSystem | PlatformPath.Path +> => Effect.gen(function* () { const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); if (!manifest) { - console.log("No local Executor server is running."); - console.log(`Start one with: ${cliPrefix} web`); + printNoRunningLocalWebApp(); return; } const { origin, auth } = manifest.connection; @@ -2281,8 +2325,14 @@ const openCommand = Command.make("open", {}, () => const url = token ? `${origin}/?_token=${token}` : origin; console.log(`Opening ${url}`); yield* openInBrowser(url); - }), -).pipe( + }); + +/** + * `executor open` — the friendly way back in. Reads the running local server's + * manifest and opens the browser straight to its `?_token=` URL, so the user + * never has to copy a bearer token out of a terminal or auth.json by hand. + */ +const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe( Command.withDescription("Open the running Executor web app in your browser, already signed in"), ); @@ -2291,6 +2341,7 @@ const root = Command.make("executor").pipe( callCommand, resumeCommand, toolsCommand, + installCommand, serverCommand, webCommand, daemonCommand, diff --git a/e2e/local/auth.test.ts b/e2e/local/auth.test.ts index a76ce2b8a..75c1c2d93 100644 --- a/e2e/local/auth.test.ts +++ b/e2e/local/auth.test.ts @@ -1,16 +1,18 @@ // Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way -// a human tests it: run the dev CLI in a real terminal, watch `executor web` -// print its one-time `?_token=` URL, then drive a browser against it. Two clean -// stories, each its own film (terminal.cast + session.mp4 spliced by -// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`): +// a human tests it: run the dev CLI in a real terminal, watch +// `executor web --foreground` print its one-time `?_token=` URL, then drive a +// browser against it. Two clean stories, each its own film (terminal.cast + +// session.mp4 spliced by scenario.ts), each booting its OWN temporary server +// (own data dir, `--port 0`): // // 1. The CLI's ?_token URL boots straight into an authenticated console. // 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the // token connects. // -// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal -// and hands the printed URL to a body; the terminal stays up until the body is -// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes. +// `withLocalServer` (shared helper) runs `executor web --foreground` in a +// recorded terminal and hands the printed URL to a body; the terminal stays up +// until the body is done, then Ctrl-C shuts it (and its vite child) down so the +// PTY closes. import { expect } from "@effect/vitest"; import { Effect } from "effect"; @@ -30,7 +32,7 @@ scenario( yield* withLocalServer(cli, runDir, ({ url, token }) => browser.session(identity, async ({ page, step }) => { - await step("Open the ?_token URL printed by executor web", async () => { + await step("Open the ?_token URL printed by executor web --foreground", async () => { await page.goto(url, { waitUntil: "domcontentloaded" }); await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); // Integrations actually LOAD (the built-in Executor source) — proves diff --git a/e2e/local/cli-open-stale-manifest.test.ts b/e2e/local/cli-open-stale-manifest.test.ts index a7d6c7795..c33aab461 100644 --- a/e2e/local/cli-open-stale-manifest.test.ts +++ b/e2e/local/cli-open-stale-manifest.test.ts @@ -77,7 +77,11 @@ scenario( }); const output = `${stdout}\n${stderr}`; expect(output, "stale endpoint should be rejected before printing the token URL").toContain( - "No local Executor server is running.", + "Executor is not running.", + ); + expect(output, "the recovery path should point users at durable setup").toContain("install"); + expect(output, "the old foreground behavior should remain discoverable").toContain( + "web --foreground", ); expect(output, "the stale bearer must not be printed").not.toContain(token); expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`); diff --git a/e2e/local/cli-web-transition.test.ts b/e2e/local/cli-web-transition.test.ts new file mode 100644 index 000000000..c1b039526 --- /dev/null +++ b/e2e/local/cli-web-transition.test.ts @@ -0,0 +1,57 @@ +// Local CLI: `executor web` used to mean "start a foreground server". The +// first-time CLI setup path is now explicit: install the durable background +// service first, then use `executor web` to open it. A fresh `web` invocation +// should guide the user without minting local-server credentials/manifest state +// or binding ports. +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +const execFileAsync = promisify(execFile); +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +scenario( + "CLI web · a fresh install points users at setup instead of starting a foreground server", + { timeout: 120_000 }, + Effect.promise(async () => { + const root = mkdtempSync(join(tmpdir(), "executor-web-transition-")); + const dataDir = join(root, "data"); + try { + const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "web"], { + cwd: repoRoot, + env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, + }); + const output = `${stdout}\n${stderr}`; + + expect(output, "plain web should not start the old foreground server").not.toContain("Open:"); + expect(output, "plain web should explain that no service is running").toContain( + "Executor is not running.", + ); + expect(output, "plain web should direct first-time users to durable setup").toContain( + "install", + ); + expect( + output, + "plain web should keep the temporary-server escape hatch discoverable", + ).toContain("web --foreground"); + expect( + existsSync(join(dataDir, "server-control", "auth.json")), + "plain web should not mint a local auth token", + ).toBe(false); + expect( + existsSync(join(dataDir, "server-control", "server.json")), + "plain web should not write a foreground server manifest", + ).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), +); diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts index 785d65d83..818b272fb 100644 --- a/e2e/local/local-server.ts +++ b/e2e/local/local-server.ts @@ -1,9 +1,9 @@ -// Shared helper for the `local` e2e project: boot a real `executor web` in a -// recorded terminal, parse its printed one-time `?_token=` URL, and run a body -// against it. Each scenario boots its OWN server (own throwaway data dir, -// `--port 0`) so files can run in parallel without colliding. The terminal -// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so -// the vite child dies and the PTY closes). +// Shared helper for the `local` e2e project: boot a real temporary server with +// `executor web --foreground` in a recorded terminal, parse its printed one-time +// `?_token=` URL, and run a body against it. Each scenario boots its OWN server +// (own throwaway data dir, `--port 0`) so files can run in parallel without +// colliding. The terminal stays up until the body settles, then Ctrl-C gives a +// graceful shutdown (so the vite child dies and the PTY closes). import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,10 +29,11 @@ export interface ServerHandle { } /** - * Boot `executor web` and run `body` against the resulting {@link ServerHandle}. - * Keeps the server up until the body settles, then Ctrl-C for a graceful - * shutdown. Cleans up the throwaway data dir. The body may drive the browser, - * a typed API client, an MCP client — anything that needs the live server. + * Boot `executor web --foreground` and run `body` against the resulting + * {@link ServerHandle}. Keeps the server up until the body settles, then Ctrl-C + * for a graceful shutdown. Cleans up the throwaway data dir. The body may drive + * the browser, a typed API client, an MCP client — anything that needs the live + * server. */ export const withLocalServer = ( cli: CliSurface, @@ -54,7 +55,7 @@ export const withLocalServer = ( yield* Effect.all( [ cli.session( - ["bun", "run", "dev:cli", "web", "--port", "0"], + ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"], async (term) => { markRecordingStart(runDir, "terminal"); markFocus(runDir, "terminal"); @@ -64,7 +65,9 @@ export const withLocalServer = ( ); const url = TOKEN_URL.exec(snapshot.text)?.[0]; if (!url) { - throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`); + throw new Error( + `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`, + ); } publishUrl(url); await bodyDone; diff --git a/e2e/local/mcp-browser-approve.test.ts b/e2e/local/mcp-browser-approve.test.ts index 172d8b809..65ce5d671 100644 --- a/e2e/local/mcp-browser-approve.test.ts +++ b/e2e/local/mcp-browser-approve.test.ts @@ -5,11 +5,12 @@ // the MCP `resume` tool (auth on the API path), so it never drives the browser // page and could not catch this. This drives the real page in a real browser. // -// Flow: boot `executor web` → create a require_approval policy on a built-in -// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser -// → the server returns a paused `approvalUrl` → open it in the browser (with the -// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a -// negative: the approval endpoint 401s without the bearer. +// Flow: boot `executor web --foreground` → create a require_approval policy on a +// built-in tool → an MCP client (bearer) executes that tool with +// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it +// in the browser (with the `?_token` bootstrap) → click Approve → the MCP +// `resume` call completes. Plus a negative: the approval endpoint 401s without +// the bearer. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { HttpApiClient } from "effect/unstable/httpapi"; From ce495bfc90b70511864447c7184c9e3654af232d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:43:30 -0700 Subject: [PATCH 3/6] Fix launchd reinstall after disable --- apps/cli/src/service.ts | 7 +++++-- apps/desktop/src/main/service.ts | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts index 0f7e01505..0e8d9bf8c 100644 --- a/apps/cli/src/service.ts +++ b/apps/cli/src/service.ts @@ -270,8 +270,12 @@ const makeLaunchdBackend = (): ServiceBackend => { yield* fs.writeFileString(plistFile, plist, { mode: 0o600 }); // Re-bootstrap cleanly: a stale registration from a prior install would - // make `bootstrap` fail with "service already loaded". + // make `bootstrap` fail with "service already loaded". `service + // uninstall` also records the label as disabled in launchd's override + // database; clear that before bootstrapping or a reinstall can fail with + // launchctl's generic "Bootstrap failed: 5" error. yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); + yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore); const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]); if (bootstrap.code !== 0) { return yield* Effect.fail( @@ -280,7 +284,6 @@ const makeLaunchdBackend = (): ServiceBackend => { ), ); } - yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore); }), uninstall: () => Effect.gen(function* () { diff --git a/apps/desktop/src/main/service.ts b/apps/desktop/src/main/service.ts index c6e5c9dde..411d68a5d 100644 --- a/apps/desktop/src/main/service.ts +++ b/apps/desktop/src/main/service.ts @@ -205,7 +205,11 @@ export const installSupervisedService = async (opts: InstallOptions): Promise Date: Sun, 14 Jun 2026 13:45:05 -0700 Subject: [PATCH 4/6] Improve CLI install progress output --- apps/cli/src/main.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index a02168c55..1c8fd893c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -2130,6 +2130,19 @@ const servicePortOption = () => .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")); +const serviceManagerName = (platform: ReturnType["platform"]): string => { + switch (platform) { + case "darwin": + return "launchd"; + case "linux": + return "systemd --user"; + case "win32": + return "Windows Task Scheduler"; + case "unsupported": + return "manual setup"; + } +}; + const installService = (port: number, commandName: string) => Effect.gen(function* () { const command = `${cliPrefix} ${commandName}`; @@ -2158,8 +2171,9 @@ const installService = (port: number, commandName: string) => const status = yield* backend.status(); if (status.registered && status.running && active.kind === "cli-daemon") { console.log( - `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`, + `Executor background service is already running at ${active.connection.origin} (pid ${active.pid}).`, ); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); return; } return yield* Effect.fail( @@ -2172,12 +2186,23 @@ const installService = (port: number, commandName: string) => ); } + const path = yield* PlatformPath.Path; + const dataDir = resolveExecutorDataDir(path); + const origin = supervisedServiceOrigin(port); + console.log("Installing Executor as a background service..."); + console.log(`Service manager: ${serviceManagerName(backend.platform)}`); + console.log(`Web UI: ${origin}`); + console.log(`Data directory: ${dataDir}`); + console.log(`Logs: ${path.join(dataDir, "logs")}`); + console.log(""); + console.log("Writing the service definition and starting Executor..."); + // The unit carries no secret: the supervised daemon mints/loads its bearer // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read // the same file — so reachability is the credential-free /api/health probe. yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); - const origin = supervisedServiceOrigin(port); + console.log(`Waiting for Executor to become reachable at ${origin}...`); const reachable = yield* waitForReachable({ check: isServerReachable(origin), timeoutMs: DAEMON_BOOT_TIMEOUT_MS, From 76b8a33d35733dd184befaff588d0d6cf7cde370 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:23:13 -0700 Subject: [PATCH 5/6] Fix local service convergence --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-desktop.yml | 12 +- apps/cli/package.json | 1 + apps/cli/src/daemon.test.ts | 131 +++ apps/cli/src/daemon.ts | 49 ++ apps/cli/src/main.ts | 152 +++- apps/cli/src/service.test.ts | 21 +- apps/cli/src/service.ts | 53 +- apps/desktop/build/entitlements.mac.plist | 4 +- apps/desktop/electron-builder.config.ts | 19 +- apps/desktop/electron-builder.e2e.config.ts | 2 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/build-sidecar.ts | 282 +------ apps/desktop/scripts/smoke-sidecar.ts | 41 +- apps/desktop/src/main/index.ts | 104 ++- apps/desktop/src/main/local-auth.ts | 48 ++ apps/desktop/src/main/service.ts | 277 ++---- apps/desktop/src/main/settings.ts | 5 +- apps/desktop/src/main/sidecar.ts | 163 ++-- apps/desktop/src/shared/server-settings.ts | 2 +- apps/desktop/src/sidecar/native-bindings.ts | 7 +- apps/desktop/src/sidecar/server.ts | 14 +- apps/local/src/serve.ts | 25 +- bun.lock | 1 + e2e/cli/service-install-takeover.test.ts | 109 +++ .../supervised-attach.test.ts | 345 ++++++-- .../supervised-regressions.test.ts | 795 +++++++++++++++--- e2e/setup/desktop-packaged.globalsetup.ts | 33 +- e2e/vitest.config.ts | 4 +- .../app/src/web/server-connection-menu.tsx | 85 +- .../plugins/desktop-settings/src/client.tsx | 350 ++++---- packages/react/src/api/local-auth.tsx | 22 +- packages/react/src/api/server-connection.tsx | 10 +- 33 files changed, 2172 insertions(+), 998 deletions(-) create mode 100644 apps/desktop/src/main/local-auth.ts create mode 100644 e2e/cli/service-install-takeover.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55076666c..937936e3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: - name: Build web app run: bun run --filter @executor-js/local build - - name: Build sidecar + stage web UI + - name: Build bundled executor env: BUN_TARGET: bun-linux-x64 run: bun ./scripts/build-sidecar.ts diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 608c458bd..cad7d4a30 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -2,8 +2,8 @@ name: Publish Desktop App run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" # Triggered manually or by publish-executor-package.yml after a CLI release -# lands. Builds Electron distributables for mac/win/linux with the Bun-compiled -# sidecar bundled in `resources/sidecar/`, then attaches them to the GitHub +# lands. Builds Electron distributables for mac/win/linux with the compiled +# executor CLI bundled in `resources/executor/`, then attaches them to the GitHub # release matching the tag so electron-updater can pick them up. on: @@ -97,17 +97,17 @@ jobs: - name: Build web app run: bun run --filter @executor-js/local build - - name: Build sidecar binary + stage web UI + - name: Build bundled executor env: BUN_TARGET: ${{ matrix.bun-target }} run: bun ./scripts/build-sidecar.ts working-directory: apps/desktop # Gate the release on the compiled binary actually booting. v1.5.0/.1 - # shipped sidecars that died on launch (missing libsql native binding) — - # a regression dev mode can't catch because `bun run` resolves + # shipped local-server binaries that died on launch (missing libsql native + # binding) — a regression dev mode can't catch because `bun run` resolves # node_modules that `bun build --compile` does not bundle. - - name: Smoke test compiled sidecar + - name: Smoke test bundled executor if: matrix.smoke run: bun run test:smoke working-directory: apps/desktop diff --git a/apps/cli/package.json b/apps/cli/package.json index 5c170ddfd..e3b9d3347 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@sentry/bun": "^10.57.0", "effect": "catalog:", "quickjs-emscripten": "catalog:" }, diff --git a/apps/cli/src/daemon.test.ts b/apps/cli/src/daemon.test.ts index 884fd9c7d..13ffdc80c 100644 --- a/apps/cli/src/daemon.test.ts +++ b/apps/cli/src/daemon.test.ts @@ -7,6 +7,7 @@ import { canAutoStartLocalDaemonForHost, isDevCliEntrypoint, isExecutorServerReachable, + planServiceInstall, } from "./daemon"; describe("isDevCliEntrypoint", () => { @@ -93,3 +94,133 @@ describe("isExecutorServerReachable", () => { }), ); }); + +describe("planServiceInstall", () => { + it("no-ops when the supervised service already runs this version on the requested port", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "cli-daemon", + activeVersion: "1.5.11", + activePort: 4789, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("noop"); + }); + + it("reinstalls when the supervised service runs an older version", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "cli-daemon", + activeVersion: "1.5.10", + activePort: 4789, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("reinstall"); + }); + + it("reinstalls when the version matches but the requested service port changed", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "cli-daemon", + activeVersion: "1.5.11", + activePort: 4789, + requestedPort: 5790, + currentVersion: "1.5.11", + }), + ).toBe("reinstall"); + }); + + it("reinstalls when the version and port match but the service points at another executable", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "cli-daemon", + activeVersion: "1.5.11", + activeExecutablePath: + "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", + activePort: 4789, + requestedPort: 4789, + currentVersion: "1.5.11", + currentExecutablePath: "/Applications/Executor.app/Contents/Resources/executor/executor", + }), + ).toBe("reinstall"); + }); + + it("takes over when a detached CLI daemon owns the manifest while the service is running elsewhere", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "cli-daemon", + activePid: 2002, + servicePid: 1001, + activeVersion: "1.5.11", + activePort: 4788, + requestedPort: 55334, + currentVersion: "1.5.11", + }), + ).toBe("takeover-then-install"); + }); + + it("reinstalls when the supervised service is up but manifest details are unavailable", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: null, + activeVersion: null, + activePort: null, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("reinstall"); + }); + + it("takes over when another local server kind owns the data directory", () => { + expect( + planServiceInstall({ + registered: true, + running: true, + activeKind: "foreground", + activeVersion: "1.5.11", + activePort: 4789, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("takeover-then-install"); + expect( + planServiceInstall({ + registered: false, + running: false, + activeKind: "desktop-sidecar", + activeVersion: "1.5.10", + activePort: 4789, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("takeover-then-install"); + }); + + it("takes over on a fresh install path before writing the service", () => { + expect( + planServiceInstall({ + registered: false, + running: false, + activeKind: null, + activeVersion: null, + activePort: null, + requestedPort: 4789, + currentVersion: "1.5.11", + }), + ).toBe("takeover-then-install"); + }); +}); diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 9acb18371..f0b1c78a2 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: { } return fallbackPort; }); + +// --------------------------------------------------------------------------- +// Service-install planning (pure) +// --------------------------------------------------------------------------- + +export type ServiceInstallPlan = "noop" | "reinstall" | "takeover-then-install"; + +export const planServiceInstall = (input: { + readonly registered: boolean; + readonly running: boolean; + readonly activeKind: "cli-daemon" | "desktop-sidecar" | "foreground" | null; + readonly activePid?: number | null; + readonly servicePid?: number | null; + readonly activeVersion: string | null; + readonly activeExecutablePath?: string | null; + readonly activePort: number | null; + readonly requestedPort: number; + readonly currentVersion: string; + readonly currentExecutablePath?: string | null; +}): ServiceInstallPlan => { + if (input.activeKind !== null && input.activeKind !== "cli-daemon") { + return "takeover-then-install"; + } + + if (input.registered && input.running) { + if ( + input.activeKind === "cli-daemon" && + input.activePid !== undefined && + input.activePid !== null && + input.servicePid !== undefined && + input.servicePid !== null && + input.activePid !== input.servicePid + ) { + return "takeover-then-install"; + } + + const executableMatches = + !input.activeExecutablePath || + !input.currentExecutablePath || + input.activeExecutablePath === input.currentExecutablePath; + return input.activeVersion === input.currentVersion && + input.activePort === input.requestedPort && + executableMatches + ? "noop" + : "reinstall"; + } + + return "takeover-then-install"; +}; diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 1c8fd893c..9174785f2 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -36,6 +36,25 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { setQuickJSModule(mod); } +const sentryDsn = process.env.EXECUTOR_SENTRY_DSN; +if (sentryDsn) { + const Sentry = await import("@sentry/bun"); + Sentry.init({ + dsn: sentryDsn, + release: process.env.EXECUTOR_SENTRY_RELEASE, + environment: process.env.EXECUTOR_SENTRY_ENVIRONMENT ?? "production", + tracesSampleRate: 0, + initialScope: { + tags: { + process: "daemon", + platform: process.platform, + arch: process.arch, + ...(process.env.EXECUTOR_RUN_ID ? { runId: process.env.EXECUTOR_RUN_ID } : {}), + }, + }, + }); +} + import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; import { BunRuntime, BunServices } from "@effect/platform-bun"; import { HttpApiClient } from "effect/unstable/httpapi"; @@ -71,6 +90,7 @@ import { isExecutorServerReachable, isDevCliEntrypoint, parseDaemonBaseUrl, + planServiceInstall, spawnDetached, waitForReachable, waitForUnreachable, @@ -100,7 +120,6 @@ import { acquireLocalServerStartLock, readLocalServerManifest, releaseLocalServerStartLock, - removeLocalServerManifest, removeLocalServerManifestIfOwnedBy, resolveExecutorDataDir, writeLocalServerManifest, @@ -260,7 +279,7 @@ const makeLocalServerManifest = (input: { scopeDir: currentScopeDirForManifest(), connection: input.connection, owner: { - client: "cli", + client: process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli", version: CLI_VERSION, executablePath: isDevMode ? (script ?? null) : process.execPath, }, @@ -286,6 +305,41 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< ); }); +const takeOverActiveLocalServer = (): Effect.Effect< + ExecutorLocalServerManifest | null, + Error, + FileSystem.FileSystem | PlatformPath.Path +> => + Effect.gen(function* () { + const manifest = yield* readLocalServerManifest(); + if (!manifest) return null; + + if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) { + yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); + return null; + } + + yield* terminatePid(manifest.pid).pipe(Effect.ignore); + const stopped = yield* waitForUnreachable({ + check: isServerReachable(manifest.connection.origin), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!stopped) { + return yield* Effect.fail( + new Error( + [ + `The existing Executor ${manifest.kind} at ${manifest.connection.origin} (pid ${manifest.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, + "Stop it manually and re-run.", + ].join("\n"), + ), + ); + } + + yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); + return manifest; + }); + const publishLocalServerManifest = (input: { readonly kind: ExecutorLocalServerKind; readonly connection: ExecutorServerConnection; @@ -897,7 +951,7 @@ const runDaemonSession = (input: { // and crash-loop under KeepAlive. (Found by a real reboot test with // integration data in the DB.) if (process.env.EXECUTOR_SUPERVISED) { - yield* removeLocalServerManifest().pipe(Effect.ignore); + yield* takeOverActiveLocalServer().pipe(Effect.ignore); } else { yield* assertNoOtherActiveLocalServer(); } @@ -907,15 +961,34 @@ const runDaemonSession = (input: { if (existing) { const existingUrl = daemonBaseUrl(existing.hostname, existing.port); if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { - return yield* Effect.fail( - new Error( - [ - `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, - `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, - `Stop it first: ${cliPrefix} daemon stop`, - ].join("\n"), - ), - ); + if (process.env.EXECUTOR_SUPERVISED) { + yield* terminatePid(existing.pid).pipe(Effect.ignore); + const stopped = yield* waitForUnreachable({ + check: isServerReachable(existingUrl), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!stopped) { + return yield* Effect.fail( + new Error( + [ + `The existing daemon for scope ${scopeId} at ${existingUrl} (pid ${existing.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, + "Stop it manually and re-run.", + ].join("\n"), + ), + ); + } + } else { + return yield* Effect.fail( + new Error( + [ + `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, + `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, + `Stop it first: ${cliPrefix} daemon stop`, + ].join("\n"), + ), + ); + } } yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); } @@ -2125,6 +2198,17 @@ const mcpCommand = Command.make( const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; +const portFromOrigin = (origin: string): number | null => { + try { + const url = new URL(origin); + if (!url.port) return url.protocol === "https:" ? 443 : 80; + const port = Number.parseInt(url.port, 10); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +}; + const servicePortOption = () => Options.integer("port") .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) @@ -2164,26 +2248,36 @@ const installService = (port: number, commandName: string) => return; } - // Don't fight an already-running local server against the same data dir - // (a desktop sidecar, a foreground `executor web`, or an existing daemon). - const active = yield* readActiveLocalServerManifest(); - if (active) { - const status = yield* backend.status(); - if (status.registered && status.running && active.kind === "cli-daemon") { + const status = yield* backend.status(); + const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + const plan = planServiceInstall({ + registered: status.registered, + running: status.running, + activeKind: active?.kind ?? null, + activePid: active?.pid ?? null, + servicePid: status.pid, + activeVersion: active?.owner.version ?? null, + activeExecutablePath: active?.owner.executablePath ?? null, + activePort: active ? portFromOrigin(active.connection.origin) : null, + requestedPort: port, + currentVersion: CLI_VERSION, + currentExecutablePath: process.execPath, + }); + + if (plan === "noop") { + const where = active ? ` at ${active.connection.origin} (pid ${active.pid})` : ""; + console.log(`Executor background service is already running${where}.`); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); + return; + } + + if (plan === "takeover-then-install") { + const replaced = yield* takeOverActiveLocalServer(); + if (replaced) { console.log( - `Executor background service is already running at ${active.connection.origin} (pid ${active.pid}).`, + `Replacing running Executor ${replaced.kind} at ${replaced.connection.origin} (pid ${replaced.pid})...`, ); - console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); - return; } - return yield* Effect.fail( - new Error( - [ - `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, - `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run \`${command}\`.`, - ].join("\n"), - ), - ); } const path = yield* PlatformPath.Path; diff --git a/apps/cli/src/service.test.ts b/apps/cli/src/service.test.ts index 4bf5979c6..825a7f95e 100644 --- a/apps/cli/src/service.test.ts +++ b/apps/cli/src/service.test.ts @@ -13,12 +13,14 @@ describe("service unit generation", () => { const launchdInput = { label: "sh.executor.daemon", programArguments: [ - "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", + "/Applications/Executor.app/Contents/Resources/executor/executor", "daemon", "run", "--foreground", "--port", "4789", + "--hostname", + "127.0.0.1", ], environment: { EXECUTOR_SUPERVISED: "1", @@ -65,13 +67,24 @@ describe("service unit generation", () => { it("renders a systemd --user unit with crash-only restart", () => { const unit = generateSystemdUnit({ - execStart: ["/usr/local/bin/executor", "daemon", "run", "--foreground", "--port", "4789"], + execStart: [ + "/usr/local/bin/executor", + "daemon", + "run", + "--foreground", + "--port", + "4789", + "--hostname", + "127.0.0.1", + ], environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, workingDirectory: "/home/x/.executor", stdoutPath: "/home/x/.executor/logs/daemon.log", stderrPath: "/home/x/.executor/logs/daemon.error.log", }); - expect(unit).toContain("ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789"); + expect(unit).toContain( + "ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789 --hostname 127.0.0.1", + ); expect(unit).toContain("Restart=on-failure"); expect(unit).toContain("WantedBy=default.target"); expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); @@ -92,7 +105,7 @@ describe("service unit generation", () => { expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); expect(wrapper).toContain( - '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789', + '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789 --hostname 127.0.0.1', ); expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); // No secret in the wrapper — the daemon reads the bearer from auth.json at boot. diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts index 0e8d9bf8c..c63e26b6a 100644 --- a/apps/cli/src/service.ts +++ b/apps/cli/src/service.ts @@ -138,27 +138,48 @@ const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray => ({ - // Marks the process as OS-supervised so the daemon resolves its bearer token - // from the durable 0600 auth.json (the secret is never in the unit itself). - EXECUTOR_SUPERVISED: "1", - // Pin the data dir explicitly: launchd/systemd give a minimal environment and - // we never want the daemon to fall back to a different home than the user's. - EXECUTOR_DATA_DIR: dataDir, - // Stamp the installing version so `service status` can flag drift after an - // upgrade where the unit still points at an older binary path. - EXECUTOR_SERVICE_VERSION: descriptor.version, - // A launchd/systemd unit starts with a bare PATH — without the user's PATH - // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may - // shell out to. `service install` runs from the user's shell, so its own - // PATH is the right one to bake in. (Reference: opencode shell-env capture.) - ...(process.env.PATH ? { PATH: process.env.PATH } : {}), -}); +): Record => { + const passThroughKeys = [ + "EXECUTOR_CLIENT", + "EXECUTOR_SENTRY_DSN", + "EXECUTOR_SENTRY_RELEASE", + "EXECUTOR_SENTRY_ENVIRONMENT", + "EXECUTOR_RUN_ID", + ] as const; + const passThrough = Object.fromEntries( + passThroughKeys.flatMap((key) => { + const value = process.env[key]; + return value ? [[key, value] as const] : []; + }), + ); + + return { + // Marks the process as OS-supervised so the daemon resolves its bearer token + // from the durable 0600 auth.json (the secret is never in the unit itself). + EXECUTOR_SUPERVISED: "1", + // Pin the data/scope dirs explicitly: launchd/systemd give a minimal + // environment and we never want the daemon to fall back to a different home + // or cwd than the user's singleton local service. + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_SCOPE_DIR: process.env.EXECUTOR_SCOPE_DIR ?? dataDir, + // Stamp the installing version so `service status` can flag drift after an + // upgrade where the unit still points at an older binary path. + EXECUTOR_SERVICE_VERSION: descriptor.version, + // A launchd/systemd unit starts with a bare PATH — without the user's PATH + // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may + // shell out to. `service install` runs from the user's shell, so its own + // PATH is the right one to bake in. (Reference: opencode shell-env capture.) + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...passThrough, + }; +}; // --------------------------------------------------------------------------- // macOS — launchd LaunchAgent (fully built) diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist index 2fe0bd08d..043ecf496 100644 --- a/apps/desktop/build/entitlements.mac.plist +++ b/apps/desktop/build/entitlements.mac.plist @@ -7,12 +7,12 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.allow-dyld-environment-variables - com.apple.security.cs.disable-library-validation diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts index 47594f8c0..b3f9340cf 100644 --- a/apps/desktop/electron-builder.config.ts +++ b/apps/desktop/electron-builder.config.ts @@ -7,20 +7,15 @@ const config: Configuration = { directories: { output: "dist", // Static build inputs live in build/ (icon.png, entitlements.mac.plist). - // Runtime resources staged at build time (sidecar binary, web-ui) live - // in resources/ and are wired in via `extraResources` below. + // Runtime resources staged at build time (the bundled executor CLI binary) + // live in resources/ and are wired in via `extraResources` below. buildResources: "build", }, files: ["out/**/*", "package.json"], extraResources: [ { - from: "resources/sidecar/", - to: "sidecar/", - filter: ["**/*"], - }, - { - from: "resources/web-ui/", - to: "web-ui/", + from: "resources/executor/", + to: "executor/", filter: ["**/*"], }, ], @@ -29,7 +24,7 @@ const config: Configuration = { // Do NOT pin `arch:` inside the target objects. The publish workflow's // matrix passes `--arm64` / `--x64` per leg; a config-level arch list // would override that flag and force every leg to build both archs from - // a single per-leg sidecar binary, shipping mismatched-arch DMGs (errno + // a single per-leg bundled executor binary, shipping mismatched-arch DMGs (errno // -86 / EBADARCH on Apple Silicon). The CLI flag is the source of truth. target: ["dmg", "zip"], hardenedRuntime: true, @@ -45,10 +40,10 @@ const config: Configuration = { }, // Same arch rule as mac (see comment above): never pin `arch:` in the // target objects. The win/linux pins used to force both archs out of a - // single x64 matrix leg, embedding an x64 sidecar binary inside the + // single x64 matrix leg, embedding an x64 executor binary inside the // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each // workflow leg's --x64/--arm64 flag decides what gets built, so an arm64 - // artifact only exists once a leg stages an arm64 sidecar for it. + // artifact only exists once a leg stages an arm64 executor for it. win: { target: ["nsis"], }, diff --git a/apps/desktop/electron-builder.e2e.config.ts b/apps/desktop/electron-builder.e2e.config.ts index b99f66a04..5e80b45b8 100644 --- a/apps/desktop/electron-builder.e2e.config.ts +++ b/apps/desktop/electron-builder.e2e.config.ts @@ -1,5 +1,5 @@ // Unsigned packaging config for e2e: produces the SAME app bundle as the -// release config (same extraResources sidecar binary + web-ui, same main/ +// release config (same bundled executor extraResource, same main/ // preload `out/`), but skips Apple signing/notarization so it builds with no // CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through // Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4d5e3a83e..4e41d763c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "scripts": { "predev": "bun run --filter @executor-js/local build", "dev": "electron-vite dev", - "prebuild": "bun run --filter @executor-js/local build && bun ./scripts/build-sidecar.ts", + "prebuild": "bun ./scripts/build-sidecar.ts", "build": "electron-vite build", "preview": "electron-vite preview", "package": "electron-builder --config electron-builder.config.ts", diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index 985f59963..cf248e910 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -1,258 +1,52 @@ /** - * Build the production sidecar binary using `bun build --compile`. - * - * Produces a fully self-contained executable that includes the Bun runtime - * plus the entire @executor-js/local server graph (including bun:sqlite, - * FumaDB, MCP, etc.). The Electron main process exec's this binary at - * runtime instead of relying on a `bun` install on the user's machine. - * - * Also stages the apps/local Vite build output as `resources/web-ui/` so - * electron-builder picks it up via extraResources. + * Stage the production local-server binary for the packaged desktop app. * + * Packaged desktop uses the same compiled `executor` CLI binary as npm installs: + * the app delegates service install/status/restart to it, and the foreground + * fallback starts `executor daemon run --foreground`. */ -import { mkdir, rm, cp, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; -import { $ } from "bun"; +import { chmod, cp, mkdir, rm } from "node:fs/promises"; +import { resolve, join } from "node:path"; const ROOT = resolve(import.meta.dir, ".."); const REPO_ROOT = resolve(ROOT, "../.."); -const APPS_LOCAL = resolve(REPO_ROOT, "apps/local"); -const SIDECAR_ENTRY = resolve(ROOT, "src/sidecar/server.ts"); -const SIDECAR_OUT_DIR = resolve(ROOT, "resources/sidecar"); -const WEB_UI_OUT_DIR = resolve(ROOT, "resources/web-ui"); -const APPS_LOCAL_DIST = resolve(APPS_LOCAL, "dist"); -const EMBEDDED_MIGRATIONS_PATH = resolve(APPS_LOCAL, "src/db/embedded-migrations.gen.ts"); -const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record | null = null;\n\nexport default migrations;\n`; - -/** - * Cross-compile target for `bun build --compile`. When unset we use Bun's - * default `bun` target (the runner's own platform). CI passes a specific - * value like `bun-darwin-x64` to produce binaries for other platforms from - * a single matrix entry. - */ -const BUN_TARGET = process.env.BUN_TARGET ?? "bun"; -const targetIsWindows = BUN_TARGET.includes("windows") || process.platform === "win32"; -const binaryName = targetIsWindows ? "executor-sidecar.exe" : "executor-sidecar"; -const sidecarBinary = resolve(SIDECAR_OUT_DIR, binaryName); - -/** - * Normalized `-[-]` key for the compile target, derived from - * BUN_TARGET (`bun` = the runner's own platform). Matches the keys used by - * apps/cli/src/build.ts's native-binding maps. - */ -const targetKey = - BUN_TARGET === "bun" - ? `${process.platform}-${process.arch}` - : BUN_TARGET.replace(/^bun-/, "").replace(/^windows-/, "win32-"); - -const targetIsCurrentPlatform = targetKey === `${process.platform}-${process.arch}`; - -// `bun build --compile` does not bundle `.node` native addons into bunfs, so -// the sidecar's eager `require('@libsql/')` (and the keychain plugin's -// lazy keyring load) would fail at runtime. We stage each binding next to the -// binary; src/sidecar/native-bindings.ts (the sidecar's first import) points -// the loaders at them via EXECUTOR_LIBSQL_NATIVE_PATH / -// EXECUTOR_KEYRING_NATIVE_PATH. Mirrors apps/cli/src/build.ts. -const LIBSQL_NATIVE_VERSION = "0.5.29"; -const resolveLibsqlNative = (): string => { - const platformMap: Record = { - "darwin-arm64": "darwin-arm64", - "darwin-x64": "darwin-x64", - // The compiled binary runs on Bun, which libSQL's loader treats as glibc - // (its musl->gnu workaround), so non-musl linux targets need the -gnu binding. - "linux-arm64": "linux-arm64-gnu", - "linux-x64": "linux-x64-gnu", - "win32-arm64": "win32-arm64-msvc", - "win32-x64": "win32-x64-msvc", - }; - const target = platformMap[targetKey]; - if (!target) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error(`No @libsql native binding mapping for target ${targetKey}`); - } - const pkg = `@libsql/${target}`; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout - try { - const req = createRequire(join(APPS_LOCAL, "package.json")); - return join(dirname(req.resolve(`${pkg}/package.json`)), "index.node"); - } catch { - const bunPath = join( - REPO_ROOT, - `node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`, - ); - if (!existsSync(bunPath)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error( - `Cannot resolve ${pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, - ); - } - return bunPath; - } -}; - -const resolveKeyringNative = (): string => { - const platformMap: Record = { - "darwin-arm64": { - pkg: "@napi-rs/keyring-darwin-arm64", - node: "keyring.darwin-arm64.node", - }, - "darwin-x64": { - pkg: "@napi-rs/keyring-darwin-x64", - node: "keyring.darwin-x64.node", - }, - "linux-arm64": { - pkg: "@napi-rs/keyring-linux-arm64-gnu", - node: "keyring.linux-arm64-gnu.node", - }, - "linux-x64": { - pkg: "@napi-rs/keyring-linux-x64-gnu", - node: "keyring.linux-x64-gnu.node", - }, - "win32-arm64": { - pkg: "@napi-rs/keyring-win32-arm64-msvc", - node: "keyring.win32-arm64-msvc.node", - }, - "win32-x64": { - pkg: "@napi-rs/keyring-win32-x64-msvc", - node: "keyring.win32-x64-msvc.node", - }, - }; - const entry = platformMap[targetKey]; - if (!entry) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error(`No @napi-rs/keyring native binding mapping for target ${targetKey}`); - } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout - try { - const req = createRequire(join(REPO_ROOT, "node_modules", "@napi-rs/keyring", "package.json")); - return join(dirname(req.resolve(`${entry.pkg}/package.json`)), entry.node); - } catch { - const bunPath = join( - REPO_ROOT, - `node_modules/.bun/${entry.pkg.replace("/", "+")}@1.2.0/node_modules/${entry.pkg}/${entry.node}`, - ); - if (!existsSync(bunPath)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error( - `Cannot resolve ${entry.pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, - ); - } - return bunPath; - } -}; - -// QuickJS ships its WASM as a side asset; `bun build --compile` can't pull -// it into bunfs, so we stage it next to the binary and the sidecar entry -// preloads it via `setQuickJSModule` before any server import. -const resolveQuickJsWasmPath = (): string => { - const req = createRequire(join(REPO_ROOT, "packages/kernel/runtime-quickjs/package.json")); - const quickJsPkg = req.resolve("quickjs-emscripten/package.json"); - const wasmPath = resolve( - dirname(quickJsPkg), - "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", - ); - if (!existsSync(wasmPath)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error(`QuickJS WASM not found at ${wasmPath}`); - } - return wasmPath; +const CLI_ROOT = resolve(REPO_ROOT, "apps/cli"); +const EXECUTOR_OUT_DIR = resolve(ROOT, "resources/executor"); + +const platformName = (platform: NodeJS.Platform): string => + platform === "win32" ? "windows" : platform; + +const currentTargetPackage = (): string => + `executor-${platformName(process.platform)}-${process.arch}`; + +const targetPackageFromBunTarget = (target: string | undefined): string | null => { + if (!target || target === "bun") return null; + const normalized = target + .replace(/^bun-/, "") + .replace(/^windows-/, "windows-") + .replace(/^win32-/, "windows-"); + return `executor-${normalized}`; }; -// The v1→v2 data migration replays the legacy v1 drizzle chain -// (apps/local/drizzle-legacy-v1) before reading a legacy database. The -// compiled sidecar cannot rely on that folder existing on disk, so inline -// every migration as text and let apps/local extract them to a temp folder -// during startup. Mirrors apps/cli/src/build.ts — embedding the wrong dir -// (e.g. the v2 chain in drizzle/) makes the sidecar treat every real legacy -// database as "history does not match" and skip the replay. -const createEmbeddedMigrationsSource = async () => { - const migrationsDir = resolve(APPS_LOCAL, "drizzle-legacy-v1"); - const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) - .map((file) => file.replaceAll("\\", "/")) - .sort(); - - const imports = files.map((file, index) => { - const spec = join(migrationsDir, file).replaceAll("\\", "/"); - return `import file_${index} from ${JSON.stringify(spec)} with { type: "text" };`; - }); - - const entries = files.map((file, index) => ` ${JSON.stringify(file)}: file_${index},`); - - return [ - "// Auto-generated - maps migration paths to inlined file contents", - ...imports, - "export default {", - ...entries, - "} as Record;", - ].join("\n"); -}; - -if (!existsSync(APPS_LOCAL_DIST)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error( - `apps/local/dist not found. Run \`bun run --filter @executor-js/local build\` first.`, - ); -} +const targetPackage = targetPackageFromBunTarget(process.env.BUN_TARGET) ?? currentTargetPackage(); +const targetArgs = process.env.BUN_TARGET ? ["--target", targetPackage] : ["--single"]; -// Cross-target builds (e.g. the mac x64 leg on an arm64 runner) need the other -// platform's optional native packages on disk before we can stage them. -// `--cpu=* --os=*` extracts them all without modifying the lockfile. Mirrors -// apps/cli/src/build.ts — Bun.spawn, not Bun.$, because the shell -// glob-expands the bare `*` in `--cpu=*` and fails with "no matches found". -if (!targetIsCurrentPlatform) { - console.log("[build-sidecar] installing optional native deps for all platforms..."); - // timeout: bun install has been observed to print a fatal error (tarball - // integrity check) and then hang instead of exiting, wedging the CI leg - // until the job-level deadline. A healthy run takes well under a minute. - const proc = Bun.spawn(["bun", "install", "--frozen-lockfile", "--cpu=*", "--os=*"], { - cwd: REPO_ROOT, - stdio: ["ignore", "inherit", "inherit"], - timeout: 10 * 60 * 1000, - killSignal: "SIGKILL", - }); - if ((await proc.exited) !== 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal - throw new Error("bun install --cpu=* --os=* failed (or timed out after 10 minutes)"); - } +console.log(`[build-sidecar] building CLI binary target ${targetPackage}`); +const build = Bun.spawn(["bun", "run", "src/build.ts", "binary", ...targetArgs], { + cwd: CLI_ROOT, + stdio: ["ignore", "inherit", "inherit"], +}); +if ((await build.exited) !== 0) { + throw new Error(`CLI binary build failed for ${targetPackage}`); } -// Resolve the native bindings up front so a missing platform package fails the -// build before the (slow) compile, and cross-target builds get a clear message. -const libsqlNativePath = resolveLibsqlNative(); -const keyringNativePath = resolveKeyringNative(); - -await rm(SIDECAR_OUT_DIR, { recursive: true, force: true }); -await rm(WEB_UI_OUT_DIR, { recursive: true, force: true }); -await mkdir(SIDECAR_OUT_DIR, { recursive: true }); -await mkdir(WEB_UI_OUT_DIR, { recursive: true }); - -console.log( - `[build-sidecar] bun build --compile --target=${BUN_TARGET} ${SIDECAR_ENTRY} → ${sidecarBinary}`, -); - -console.log("[build-sidecar] generating embedded drizzle migrations"); -const embeddedMigrations = await createEmbeddedMigrationsSource(); -await writeFile(EMBEDDED_MIGRATIONS_PATH, `${embeddedMigrations}\n`); - -// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time script must restore the checked-in migration stub after compile failure -try { - await $`bun build --compile --minify --sourcemap --target=${BUN_TARGET} --outfile ${sidecarBinary} ${SIDECAR_ENTRY}`.cwd( - REPO_ROOT, - ); - - console.log(`[build-sidecar] staging QuickJS WASM → ${SIDECAR_OUT_DIR}`); - await cp(resolveQuickJsWasmPath(), join(SIDECAR_OUT_DIR, "emscripten-module.wasm")); - - console.log(`[build-sidecar] staging native bindings (${targetKey}) → ${SIDECAR_OUT_DIR}`); - await cp(libsqlNativePath, join(SIDECAR_OUT_DIR, "libsql.node")); - await cp(keyringNativePath, join(SIDECAR_OUT_DIR, "keyring.node")); +const sourceBinDir = join(CLI_ROOT, "dist", targetPackage, "bin"); +await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true }); +await mkdir(EXECUTOR_OUT_DIR, { recursive: true }); +await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true }); - console.log(`[build-sidecar] staging web UI → ${WEB_UI_OUT_DIR}`); - await cp(APPS_LOCAL_DIST, WEB_UI_OUT_DIR, { recursive: true }); -} finally { - await writeFile(EMBEDDED_MIGRATIONS_PATH, EMBEDDED_MIGRATIONS_STUB); +if (process.platform !== "win32") { + await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755); } -console.log("[build-sidecar] done"); +console.log(`[build-sidecar] staged bundled executor → ${EXECUTOR_OUT_DIR}`); diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index ca6e466fc..88a6ee9b5 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -1,14 +1,14 @@ /** - * End-to-end smoke test for the compiled sidecar binary. + * End-to-end smoke test for the bundled executor binary. * * Catches "works in dev, breaks in --compile" regressions: bunfs asset - * loading (QuickJS WASM, staged web UI), native + * loading (embedded web UI, QuickJS WASM), native * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. * * Flow: * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). - * 2. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 - * and parse the `EXECUTOR_READY:` sentinel. + * 2. Spawn the compiled `executor daemon run --foreground --port 0` + * and parse the ready URL. * 3. Connect via MCP streamable HTTP, call the `execute` tool with code * that registers and invokes the OpenAPI tool, assert the answer * round-trips as 42. @@ -29,8 +29,8 @@ const ROOT = resolve(import.meta.dir, ".."); const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); const BINARY = resolve( ROOT, - "resources/sidecar", - process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar", + "resources/executor", + process.platform === "win32" ? "executor.exe" : "executor", ); const AUTH_TOKEN = "smoke-test-token"; @@ -38,7 +38,7 @@ const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`; const READY_TIMEOUT_MS = 30_000; // Throw instead of process.exit so main()'s finally still tears down the -// spawned sidecar + temp dirs — exiting here leaks a running sidecar process. +// spawned daemon + temp dirs — exiting here leaks a running process. const fail = (msg: string): never => { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error throw new Error(`[smoke-sidecar] FAIL: ${msg}`); @@ -246,7 +246,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise { const deadline = setTimeout(() => { // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness reporting a build-time timeout - rejectReady(new Error(`sidecar did not announce ready within ${READY_TIMEOUT_MS}ms`)); + rejectReady(new Error(`daemon did not announce ready within ${READY_TIMEOUT_MS}ms`)); }, READY_TIMEOUT_MS); let stdoutBuf = ""; @@ -258,7 +258,7 @@ const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise): Promise { console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); const proc = spawn({ - cmd: [BINARY], + cmd: [ + BINARY, + "daemon", + "run", + "--foreground", + "--port", + "0", + "--hostname", + "127.0.0.1", + "--auth-token", + AUTH_TOKEN, + ], env: { ...process.env, - EXECUTOR_PORT: "0", - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_TOKEN: AUTH_TOKEN, EXECUTOR_SCOPE_DIR: scopeDir, EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_CLIENT: "desktop", XDG_DATA_HOME: xdgDir, }, stdin: "ignore", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bf1cf32c4..e12c77359 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -36,6 +36,7 @@ import { } from "./diagnostics"; import { sidecarCrashHtml } from "./crash-screen"; import { + bundledExecutorPath, installSupervisedService, restartSupervisedService, supervisedServiceStatus, @@ -117,18 +118,66 @@ const stopConnection = async (conn: SidecarConnection): Promise => { await stopSidecar(conn.child); }; +const webUrlForConnection = (conn: SidecarConnection): string => { + const url = new URL(conn.baseUrl); + if (conn.authToken) url.searchParams.set("_token", conn.authToken); + url.searchParams.set("_executor_desktop_launch", String(process.pid)); + return url.toString(); +}; + // The supervised daemon (and the desktop sidecar) own this data dir — the same // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. const DESKTOP_DATA_DIR = join(homedir(), ".executor"); const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +const parseVersionParts = (version: string): readonly number[] | null => { + const core = version.trim().split(/[+-]/, 1)[0]; + if (!core) return null; + const parts = core.split(".").map((part) => Number.parseInt(part, 10)); + return parts.every((part) => Number.isInteger(part) && part >= 0) ? parts : null; +}; + +const compareVersions = (left: string, right: string): number | null => { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + if (!leftParts || !rightParts) return null; + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const l = leftParts[index] ?? 0; + const r = rightParts[index] ?? 0; + if (l !== r) return l > r ? 1 : -1; + } + return 0; +}; + +const shouldUpgradeDaemonForDesktop = (daemonVersion: string | null): boolean => { + if (!daemonVersion) return false; + const comparison = compareVersions(app.getVersion(), daemonVersion); + return comparison !== null && comparison > 0; +}; + +const normalizedPath = (path: string): string => path.replaceAll("\\", "/"); + +const shouldReplaceDaemonForDesktop = (conn: SidecarConnection): boolean => { + if (shouldUpgradeDaemonForDesktop(conn.ownerVersion)) return true; + if (!app.isPackaged) return false; + const ownerPath = conn.ownerExecutablePath; + if (!ownerPath) return false; + if (!existsSync(ownerPath)) return true; + if (conn.ownerClient !== "desktop") return false; + return normalizedPath(ownerPath) !== normalizedPath(bundledExecutorPath()); +}; + /** Poll for a reachable supervised daemon until the deadline. */ -const waitForSupervisedAttach = async (timeoutMs: number): Promise => { +const waitForSupervisedAttach = async ( + timeoutMs: number, + options: { readonly port?: number } = {}, +): Promise => { const deadline = Date.now() + timeoutMs; for (;;) { const attached = await attachToSupervisedDaemon(); - if (attached) return attached; + if (attached && (options.port === undefined || attached.port === options.port)) return attached; if (Date.now() >= deadline) return null; await delay(300); } @@ -140,7 +189,7 @@ const confirmEnableBackgroundService = async (): Promise => { title: "Keep Executor running in the background?", message: "Keep your connections available after you quit Executor?", detail: - "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.", + "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.", buttons: ["Keep running in the background", "Not now"], defaultId: 0, cancelId: 1, @@ -156,7 +205,21 @@ const confirmEnableBackgroundService = async (): Promise => { const ensureSupervisedConnection = async (): Promise => { // 1. Already running → attach. const attached = await attachToSupervisedDaemon(); - if (attached) return attached; + if (attached) { + if (!shouldReplaceDaemonForDesktop(attached)) return attached; + const settings = getServerSettings(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: desktop launch should attach to the running daemon if automatic upgrade fails + try { + await installSupervisedService({ + port: settings.port, + dataDir: DESKTOP_DATA_DIR, + }); + return (await waitForSupervisedAttach(30_000, { port: settings.port })) ?? attached; + } catch (error) { + log.warn("Failed to replace older supervised daemon; attaching to the running daemon", error); + return attached; + } + } const status = await supervisedServiceStatus(); if (!status.supported) return null; @@ -189,9 +252,9 @@ const ensureSupervisedConnection = async (): Promise = return waitForSupervisedAttach(15_000); }; -// Crash monitor for the supervised daemon: launchd restarts it on crash, but -// during that window the window's requests fail. Poll, show a reconnecting -// overlay while it's down, and reload once it's back. +// Crash monitor for the supervised daemon: the OS service manager restarts it +// on crash, but during that window the window's requests fail. Poll, show a +// reconnecting overlay while it's down, and reload once it's back. let supervisedMonitorTimer: ReturnType | null = null; let supervisedDaemonDown = false; @@ -219,7 +282,7 @@ const armSupervisedMonitor = () => { supervisedDaemonDown = false; connection = live; installBearerAuthHeader(live.baseUrl, live.authToken); - if (window) void window.loadURL(live.baseUrl); + if (window) void window.loadURL(webUrlForConnection(live)); } })(); }, 10_000); @@ -380,7 +443,7 @@ const createWindow = async (conn: SidecarConnection) => { return { action: "deny" }; }); - await window.loadURL(conn.baseUrl); + await window.loadURL(webUrlForConnection(conn)); }; const showPortInUseDialog = async (port: number) => { @@ -418,11 +481,12 @@ const restartSidecarAndReload = async (): Promise => { // A supervised daemon owns its own process lifetime. Re-installing the unit // rewrites settings such as the configured port, then launchd restarts it. if (connection?.supervisedDaemon) { + const port = getServerSettings().port; await installSupervisedService({ - port: getServerSettings().port, + port, dataDir: DESKTOP_DATA_DIR, }); - const next = await waitForSupervisedAttach(30_000); + const next = await waitForSupervisedAttach(30_000, { port }); if (!next) { // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to renderer as a rejected IPC call throw new Error("Supervised daemon failed to restart — see Settings"); @@ -430,7 +494,7 @@ const restartSidecarAndReload = async (): Promise => { connection = next; installBearerAuthHeader(next.baseUrl, next.authToken); const window = liveMainWindow(); - if (window) await window.loadURL(next.baseUrl); + if (window) await window.loadURL(webUrlForConnection(next)); return toDesktopServerConnection(next); } if (connection) { @@ -445,7 +509,7 @@ const restartSidecarAndReload = async (): Promise => { connection = next; installBearerAuthHeader(next.baseUrl, next.authToken); const window = liveMainWindow(); - if (window) await window.loadURL(next.baseUrl); + if (window) await window.loadURL(webUrlForConnection(next)); return toDesktopServerConnection(next); }; @@ -457,7 +521,7 @@ const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnec key: "desktop-sidecar", origin: conn.baseUrl, apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, - displayName: "Desktop sidecar", + displayName: "Local Executor", }); const registerIpcHandlers = () => { @@ -486,14 +550,14 @@ const registerIpcHandlers = () => { connection = active; installBearerAuthHeader(active.baseUrl, active.authToken); const window = liveMainWindow(); - if (window) await window.loadURL(active.baseUrl); + if (window) await window.loadURL(webUrlForConnection(active)); return toDesktopServerConnection(active); } return restartSidecarAndReload(); }); - // Background-service control surface (macOS) — lets a Settings toggle enable - // or disable the supervised daemon. Disabling tears down the service and - // falls back to a managed sidecar on next launch. + // Background-service control surface — lets a Settings toggle enable or + // disable the supervised daemon. Disabling tears down the service and falls + // back to a managed sidecar on next launch. ipcMain.handle("executor:service:status", () => supervisedServiceStatus()); ipcMain.handle( "executor:service:set-enabled", @@ -512,7 +576,7 @@ const registerIpcHandlers = () => { armSupervisedMonitor(); installBearerAuthHeader(next.baseUrl, next.authToken); const window = liveMainWindow(); - if (window) await window.loadURL(next.baseUrl); + if (window) await window.loadURL(webUrlForConnection(next)); } return true; } @@ -773,7 +837,7 @@ const boot = async () => { // Prefer an OS-supervised daemon: attach to one that's running, kick one // that's installed, or offer to install on first run. Quitting the app then // leaves MCP serving. This is also the clean handoff that replaces the old - // "another server owns the data dir → fatal error" path. Packaged macOS only; + // "another server owns the data dir → fatal error" path. Packaged builds only; // dev and unsupported platforms keep managed-spawn. if (app.isPackaged) { const supervised = await ensureSupervisedConnection(); diff --git a/apps/desktop/src/main/local-auth.ts b/apps/desktop/src/main/local-auth.ts new file mode 100644 index 000000000..18b2de019 --- /dev/null +++ b/apps/desktop/src/main/local-auth.ts @@ -0,0 +1,48 @@ +/** + * Tiny desktop-main copy of the local bearer-token file contract. + * + * Keep this module free of @executor-js/local imports. The Electron main + * process only needs to mint/read/rotate auth.json so it can pass the bearer + * to the sidecar and inject it into the renderer session; importing the local + * server package here drags the whole server/native LibSQL graph into app.asar. + */ +import { randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const resolveExecutorDataDir = (): string => + resolve(process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor")); + +const serverControlDir = (dataDir: string): string => join(dataDir, "server-control"); + +const localAuthTokenPath = (dataDir: string = resolveExecutorDataDir()): string => + join(serverControlDir(dataDir), "auth.json"); + +const mintToken = (): string => randomBytes(32).toString("base64url"); + +const readToken = (path: string): string | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional on-disk secret may be absent or malformed + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: auth.json is a tiny local boot secret outside the Effect graph + const parsed = JSON.parse(readFileSync(path, "utf8")) as { readonly token?: unknown }; + return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null; + } catch { + return null; + } +}; + +const writeToken = (dataDir: string, token: string): string => { + const dir = serverControlDir(dataDir); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "auth.json"); + writeFileSync(path, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + return token; +}; + +export const loadOrMintLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => + readToken(localAuthTokenPath(dataDir)) ?? writeToken(dataDir, mintToken()); + +export const rotateLocalAuthToken = (dataDir: string = resolveExecutorDataDir()): string => + writeToken(dataDir, mintToken()); diff --git a/apps/desktop/src/main/service.ts b/apps/desktop/src/main/service.ts index 411d68a5d..ae534a299 100644 --- a/apps/desktop/src/main/service.ts +++ b/apps/desktop/src/main/service.ts @@ -1,25 +1,11 @@ -/** - * Desktop-side manager for the OS-supervised Executor daemon (macOS launchd). - * - * The desktop drives launchd directly — it writes a LaunchAgent that runs the - * bundled `executor-sidecar` binary in supervised mode (EXECUTOR_SUPERVISED=1), - * so the daemon outlives the app and restarts on login. The app is then a thin - * client that attaches to it (see sidecar.ts `attachToSupervisedDaemon`). We do - * NOT use SMAppService: its plist must be code-signed into the bundle, whereas - * this dynamic plist points at the bundle's absolute sidecar path. The unit - * carries no secret — the daemon mints/loads its bearer from auth.json. - * - * The plist skeleton mirrors apps/cli/src/service.ts `generateLaunchdPlist` - * (the CLI is the canonical copy); keep the two in sync if the format changes. - */ - +/* 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 */ import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir, userInfo } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; import log from "electron-log/main.js"; +import { sidecarCrashReportingEnv } from "./diagnostics"; const serviceLog = log.scope("service"); const execFileAsync = promisify(execFile); @@ -32,112 +18,29 @@ interface CommandResult { readonly stderr: string; } -const runCommand = async (cmd: string, args: string[]): Promise => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capture exit code rather than throw on non-zero - try { - const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "utf8" }); - return { code: 0, stdout, stderr }; - } catch (error) { - const err = error as { code?: number | string; stdout?: string; stderr?: string }; - if (typeof err.code === "string") { - // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: command could not be spawned - throw new Error(`Failed to run \`${cmd}\`: ${err.code}`); - } - return { - code: typeof err.code === "number" ? err.code : 1, - stdout: err.stdout ?? "", - stderr: err.stderr ?? "", - }; - } -}; - -const currentUid = (): number => { - const getuid = (process as { getuid?: () => number }).getuid; - return typeof getuid === "function" ? getuid.call(process) : userInfo().uid; -}; - -const xmlEscape = (value: string): string => - value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - -const launchAgentsDir = (): string => join(homedir(), "Library", "LaunchAgents"); -const plistPath = (): string => join(launchAgentsDir(), `${SERVICE_LABEL}.plist`); -const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`; - -const sidecarBinaryPath = (): string => { - const name = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; - return join(process.resourcesPath, "sidecar", name); -}; - -const webUiDir = (): string => join(process.resourcesPath, "web-ui"); +export interface SupervisedServiceStatus { + readonly supported: boolean; + readonly registered: boolean; + readonly running: boolean; +} -interface PlistOptions { - readonly label: string; - readonly programArguments: ReadonlyArray; - readonly environment: Record; - readonly stdoutPath: string; - readonly stderrPath: string; - readonly workingDirectory: string; +export interface InstallOptions { + readonly port: number; + readonly dataDir: string; } -const generateLaunchdPlist = (options: PlistOptions): string => { - const programArgs = options.programArguments - .map((arg) => ` ${xmlEscape(arg)}`) - .join("\n"); - const envEntries = Object.entries(options.environment) - .map( - ([key, value]) => - ` ${xmlEscape(key)}\n ${xmlEscape(value)}`, - ) - .join("\n"); - return ` - - - - Label - ${xmlEscape(options.label)} - ProgramArguments - -${programArgs} - - EnvironmentVariables - -${envEntries} - - RunAtLoad - - KeepAlive - - SuccessfulExit - - - ProcessType - Background - WorkingDirectory - ${xmlEscape(options.workingDirectory)} - StandardOutPath - ${xmlEscape(options.stdoutPath)} - StandardErrorPath - ${xmlEscape(options.stderrPath)} - - -`; -}; +export const bundledExecutorPath = (): string => + join( + process.resourcesPath, + "executor", + process.platform === "win32" ? "executor.exe" : "executor", + ); + +const executorAvailable = (): boolean => app.isPackaged && existsSync(bundledExecutorPath()); -/** - * Capture the user's login-shell PATH. A launchd daemon starts with a bare - * PATH; without the user's PATH the daemon can't find pyenv/nvm/Homebrew tools - * that integrations may shell out to. Falls back to the app's own PATH. - * (Reference: opencode's shell-env capture.) - */ const captureUserPath = async (): Promise => { const shell = process.env.SHELL; if (!shell) return process.env.PATH; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a slow/odd login shell must not break install try { const { stdout } = await execFileAsync(shell, ["-ilc", 'printf "%s" "$PATH"'], { encoding: "utf8", @@ -150,87 +53,91 @@ const captureUserPath = async (): Promise => { } }; -export interface SupervisedServiceStatus { - readonly supported: boolean; - readonly registered: boolean; - readonly running: boolean; -} +const serviceEnv = async (dataDir: string): Promise => ({ + ...process.env, + ...(await captureUserPath().then((path) => (path ? { PATH: path } : {}))), + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_SCOPE_DIR: dataDir, + EXECUTOR_CLIENT: "desktop", + ...sidecarCrashReportingEnv(), +}); + +const runExecutor = async ( + args: ReadonlyArray, + options: { readonly dataDir: string }, +): Promise => { + const bin = bundledExecutorPath(); + try { + const { stdout, stderr } = await execFileAsync(bin, [...args], { + encoding: "utf8", + env: await serviceEnv(options.dataDir), + }); + return { code: 0, stdout, stderr }; + } catch (error) { + const err = error as { code?: number | string; stdout?: string; stderr?: string }; + if (typeof err.code === "string") { + throw new Error(`Failed to run \`${bin}\`: ${err.code}`); + } + return { + code: typeof err.code === "number" ? err.code : 1, + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + }; + } +}; -const isSupported = (): boolean => app.isPackaged && process.platform === "darwin"; +const statusValue = (stdout: string, key: "Registered" | "Running"): boolean => + new RegExp(`^${key}:\\s+yes(?:\\s|$)`, "im").test(stdout); export const supervisedServiceStatus = async (): Promise => { - if (!isSupported()) return { supported: false, registered: false, running: false }; - const registered = existsSync(plistPath()); - const print = await runCommand("launchctl", ["print", serviceTarget(currentUid())]); - return { supported: true, registered, running: print.code === 0 }; + if (!executorAvailable()) return { supported: false, registered: false, running: false }; + const dataDir = join(app.getPath("home"), ".executor"); + const result = await runExecutor(["service", "status"], { dataDir }); + if (result.code !== 0) { + serviceLog.warn(`service status failed: ${result.stderr || result.stdout}`); + return { supported: true, registered: false, running: false }; + } + const supported = !/^Platform:\s+unsupported$/im.test(result.stdout); + return { + supported, + registered: supported && statusValue(result.stdout, "Registered"), + running: supported && statusValue(result.stdout, "Running"), + }; }; -export interface InstallOptions { - readonly port: number; - readonly dataDir: string; -} - -/** - * Register + start the supervised daemon (the bundled sidecar under launchd). - * The unit carries no secret — the daemon mints/loads its bearer from auth.json - * under EXECUTOR_DATA_DIR, and desktop/CLI clients read the same file. - */ export const installSupervisedService = async (opts: InstallOptions): Promise => { - const uid = currentUid(); - const logs = join(opts.dataDir, "logs"); - mkdirSync(launchAgentsDir(), { recursive: true }); - mkdirSync(logs, { recursive: true }); - - const userPath = await captureUserPath(); - const environment: Record = { - EXECUTOR_SUPERVISED: "1", - EXECUTOR_PORT: String(opts.port), - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_DATA_DIR: opts.dataDir, - EXECUTOR_SCOPE_DIR: opts.dataDir, - EXECUTOR_CLIENT_DIR: webUiDir(), - EXECUTOR_CLIENT: "desktop", - EXECUTOR_SERVICE_VERSION: app.getVersion() || "", - ...(userPath ? { PATH: userPath } : {}), - }; - - const plist = generateLaunchdPlist({ - label: SERVICE_LABEL, - programArguments: [sidecarBinaryPath()], - environment, - stdoutPath: join(logs, "daemon.log"), - stderrPath: join(logs, "daemon.error.log"), - workingDirectory: opts.dataDir, + if (!executorAvailable()) { + throw new Error("Bundled executor binary is not available."); + } + const result = await runExecutor(["install", "--port", String(opts.port)], { + dataDir: opts.dataDir, }); - writeFileSync(plistPath(), plist, { mode: 0o600 }); - - // Re-bootstrap cleanly so a stale registration doesn't make bootstrap fail. - // `uninstallSupervisedService` also records the label as disabled in - // launchd's override database; clear that before bootstrapping or reinstall - // can fail with launchctl's generic "Bootstrap failed: 5" error. - await runCommand("launchctl", ["bootout", serviceTarget(uid)]); - await runCommand("launchctl", ["enable", serviceTarget(uid)]); - const bootstrap = await runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath()]); - if (bootstrap.code !== 0) { - // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: surfaces to the boot flow - throw new Error( - `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`, - ); + if (result.code !== 0) { + throw new Error((result.stderr || result.stdout).trim() || "`executor install` failed."); } - serviceLog.info(`installed supervised service on port ${opts.port}`); + serviceLog.info(`installed supervised service via bundled executor on port ${opts.port}`); }; export const uninstallSupervisedService = async (dataDir: string): Promise => { - const uid = currentUid(); - await runCommand("launchctl", ["bootout", serviceTarget(uid)]); - await runCommand("launchctl", ["disable", serviceTarget(uid)]); - rmSync(plistPath(), { force: true }); - // Clean up a legacy service.key from a pre-bearer install (best-effort). - rmSync(join(dataDir, "server-control", "service.key"), { force: true }); - serviceLog.info("uninstalled supervised service"); + if (!executorAvailable()) return; + const result = await runExecutor(["service", "uninstall"], { dataDir }); + if (result.code !== 0) { + throw new Error( + (result.stderr || result.stdout).trim() || "`executor service uninstall` failed.", + ); + } + serviceLog.info("uninstalled supervised service via bundled executor"); }; -/** Restart the supervised daemon atomically (kill + relaunch via launchd). */ export const restartSupervisedService = async (): Promise => { - await runCommand("launchctl", ["kickstart", "-k", serviceTarget(currentUid())]); + if (!executorAvailable()) { + throw new Error("Bundled executor binary is not available."); + } + const dataDir = join(app.getPath("home"), ".executor"); + const result = await runExecutor(["service", "restart"], { dataDir }); + if (result.code !== 0) { + throw new Error( + (result.stderr || result.stdout).trim() || "`executor service restart` failed.", + ); + } }; diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index 649129164..ff1c45856 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -1,5 +1,5 @@ import Store from "electron-store"; -import { rotateLocalAuthToken } from "@executor-js/local/auth"; +import { rotateLocalAuthToken } from "./local-auth"; import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; interface PersistedShape { @@ -9,6 +9,9 @@ interface PersistedShape { const store = new Store({ name: "settings", + ...(process.env.EXECUTOR_DESKTOP_SETTINGS_DIR + ? { cwd: process.env.EXECUTOR_DESKTOP_SETTINGS_DIR } + : {}), defaults: { server: DEFAULT_SERVER_SETTINGS }, }); diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 37eafb57e..6e163a81c 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -2,13 +2,11 @@ * Sidecar lifecycle manager run inside the Electron main process. * * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`. - * In prod: spawns the Bun-compiled `executor-sidecar` binary shipped under - * `process.resourcesPath/sidecar/`. + * In prod: spawns the bundled CLI binary in foreground daemon mode. * * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN - * via env, calls `startServer()` from `@executor-js/local`, and announces a - * single sentinel line on stdout (`EXECUTOR_READY:`) so this controller - * can resolve the connection promise. + * The dev sidecar announces `EXECUTOR_READY:`. The packaged CLI daemon + * announces `Daemon ready on http://host:port`. This controller accepts both. */ import { spawn, type ChildProcess } from "node:child_process"; @@ -23,7 +21,7 @@ import { parseExecutorLocalServerManifest, serializeExecutorLocalServerManifest, } from "@executor-js/sdk/shared"; -import { loadOrMintLocalAuthToken } from "@executor-js/local/auth"; +import { loadOrMintLocalAuthToken } from "./local-auth"; import { getServerSettings } from "./settings"; import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; @@ -79,6 +77,9 @@ export interface SidecarConnection { * leave MCP serving. */ readonly supervisedDaemon: boolean; + readonly ownerVersion: string | null; + readonly ownerClient: "cli" | "desktop"; + readonly ownerExecutablePath: string | null; } export class SidecarPortInUseError extends Error { @@ -217,40 +218,48 @@ const writeSidecarManifest = (input: { sidecarManifestPathByPid.set(input.childPid, input.dataDir); }; -const resolveSidecarCommand = (): { command: string; args: string[]; cwd: string } => { +const resolveSidecarCommand = (input: { + readonly port: number; + readonly hostname: string; + readonly authToken: string; +}): { command: string; args: string[]; cwd: string; cliManagedManifest: boolean } => { if (app.isPackaged) { - const binaryName = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; - const binaryPath = join(process.resourcesPath, "sidecar", binaryName); - return { command: binaryPath, args: [], cwd: process.resourcesPath }; + const binaryName = process.platform === "win32" ? "executor.exe" : "executor"; + const binaryPath = join(process.resourcesPath, "executor", binaryName); + return { + command: binaryPath, + args: [ + "daemon", + "run", + "--foreground", + "--port", + String(input.port), + "--hostname", + input.hostname, + "--auth-token", + input.authToken, + ], + cwd: process.resourcesPath, + cliManagedManifest: true, + }; } // Dev: run the TS source directly via bun on PATH. const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); const sidecarSource = resolve(repoRoot, "apps/desktop/src/sidecar/server.ts"); - return { command: "bun", args: ["run", sidecarSource], cwd: repoRoot }; + return { command: "bun", args: ["run", sidecarSource], cwd: repoRoot, cliManagedManifest: false }; }; const resolveClientDir = (): string => { - if (app.isPackaged) { - return join(process.resourcesPath, "web-ui"); - } const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); return resolve(repoRoot, "apps/local/dist"); }; +const delay = (ms: number): Promise => + new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); + export async function startSidecar(options: StartOptions = {}): Promise { const hostname = options.hostname ?? "127.0.0.1"; const settings = getServerSettings(); - const clientDir = resolveClientDir(); - const { command, args, cwd } = resolveSidecarCommand(); - - if (!existsSync(clientDir)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: misconfiguration is fatal - // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process - throw new Error( - `Executor client bundle not found at ${clientDir}. Run \`bun run --filter @executor-js/local build\` before launching desktop.`, - ); - } - // data.db and the optional executor.jsonc plugin manifest live under // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI // 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 { if (startupLockReleased) return; startupLockReleased = true; - releaseStartupLock(); + releaseStartupLock?.(); }; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures - try { - assertNoOtherLocalServerOwner(dataDir); - } catch (error) { - releaseLock(); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock - throw error; + if (!cliManagedManifest) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures + try { + assertNoOtherLocalServerOwner(dataDir); + } catch (error) { + releaseLock(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock + throw error; + } } let child: ChildProcess; @@ -300,7 +325,7 @@ export async function startSidecar(options: StartOptions = {}): Promise { const text = chunk.toString("utf8"); - process.stdout.write(`[executor-sidecar] ${text}`); + process.stdout.write(`[executor-server] ${text}`); logStdoutLine(text); - const match = text.match(/EXECUTOR_READY:(\d+)/); + const match = + text.match(/EXECUTOR_READY:(\d+)/) ?? + text.match(/Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/); if (match && !resolved) { if (!child.pid) { reject( @@ -348,13 +375,15 @@ export async function startSidecar(options: StartOptions = {}): Promise { const text = chunk.toString("utf8"); stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT); - process.stderr.write(`[executor-sidecar] ${text}`); + process.stderr.write(`[executor-server] ${text}`); logStderrLine(text); }; @@ -410,21 +442,25 @@ export async function startSidecar(options: StartOptions = {}): Promise => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1500); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal - try { - const response = await fetch(new URL("/api/health", origin), { - signal: controller.signal, - redirect: "manual", - }); - const body = await response.text(); - return response.ok && body.trim() === "ok"; - } catch { - return false; - } finally { - clearTimeout(timer); + for (let attempt = 0; attempt < 3; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch rejects on a down server; that's the "not reachable" signal + try { + const response = await fetch(new URL("/api/health", origin), { + signal: controller.signal, + redirect: "manual", + }); + const body = await response.text(); + if (response.ok && body.trim() === "ok") return true; + } catch { + // retry below + } finally { + clearTimeout(timer); + } + if (attempt < 2) await delay(150); } + return false; }; /** @@ -450,7 +486,11 @@ export async function attachToSupervisedDaemon(): Promise')` / keyring walk inside -// the binary fails. build-sidecar.ts copies each platform's `.node` next to -// the executable (`libsql.node`, `keyring.node`); here we publish their +// the binary fails. If this dev sidecar is compiled directly, copy each +// platform's `.node` next to the executable (`libsql.node`, `keyring.node`); +// here we publish their // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. // // This MUST be the FIRST import in server.ts. ES modules evaluate every import diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts index 263f069f5..a4b5242f1 100644 --- a/apps/desktop/src/sidecar/server.ts +++ b/apps/desktop/src/sidecar/server.ts @@ -1,7 +1,7 @@ /** - * Bun-side sidecar entry. Spawned by the Electron main process as a child - * process (either via `bun run ...` in dev or as a Bun-compiled binary in - * production). + * Bun-side sidecar entry. Spawned by the Electron main process in dev via + * `bun run ...`. Packaged desktop uses the bundled `executor` CLI binary + * instead. * * Reads connection parameters from env, boots the executor server, then * announces readiness with the resolved port on stdout so the Electron @@ -12,11 +12,9 @@ import "./native-bindings"; import { dirname, join } from "node:path"; -// Pre-load QuickJS WASM for compiled binaries. `bun build --compile` can't -// embed the side-asset WASM that `quickjs-emscripten` ships with, so -// build-sidecar.ts stages it next to this binary and we feed the bytes in -// via `setQuickJSModule` before any server import touches QuickJS. Mirrors -// the CLI's preload in apps/cli/src/main.ts. +// Pre-load QuickJS WASM for manually compiled sidecar binaries. Packaged +// desktop no longer ships this entrypoint, but keeping the preload here lets +// direct sidecar smoke/debug runs behave like the CLI binary. const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm"); if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { const { setQuickJSModule } = await import("@executor-js/runtime-quickjs"); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 1caa824b0..ee754a261 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -30,6 +30,11 @@ import { type StaticHandler = () => Response | Promise; +const htmlResponse = (file: Bun.BunFile): Response => + new Response(file, { + headers: { "content-type": "text/html", "cache-control": "no-store" }, + }); + function collectStaticRoutes(dir: string, prefix = ""): Record { const routes: Record = {}; // 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 - new Response(file, { - headers: { "content-type": file.type || "application/octet-stream" }, - }); + routePath === "/index.html" + ? htmlResponse(file) + : new Response(file, { + headers: { "content-type": file.type || "application/octet-stream" }, + }); } } } catch {} @@ -60,9 +67,11 @@ function embeddedToStaticRoutes(embedded: Record): Record - new Response(file, { - headers: { "content-type": file.type || "application/octet-stream" }, - }); + key === "index.html" + ? htmlResponse(file) + : new Response(file, { + headers: { "content-type": file.type || "application/octet-stream" }, + }); } return routes; } @@ -300,11 +309,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise new Response(indexFile, { headers: { "content-type": "text/html" } }); + serveIndex = () => htmlResponse(indexFile); } else { staticRoutes = collectStaticRoutes(clientDir); const indexFile = Bun.file(join(clientDir, "index.html")); - serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } }); + serveIndex = () => htmlResponse(indexFile); } const server = Bun.serve({ diff --git a/bun.lock b/bun.lock index aead83d8c..a5c103cf5 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@sentry/bun": "^10.57.0", "effect": "catalog:", "quickjs-emscripten": "catalog:", }, diff --git a/e2e/cli/service-install-takeover.test.ts b/e2e/cli/service-install-takeover.test.ts new file mode 100644 index 000000000..e14c6b1b2 --- /dev/null +++ b/e2e/cli/service-install-takeover.test.ts @@ -0,0 +1,109 @@ +/* oxlint-disable executor/no-conditional-tests -- e2e scenario uses try/finally to restore the VM service after assertions */ +// Real VM e2e for the upgrade path: `executor service install` must take over +// a same-data-dir predecessor instead of refusing and leaving users to find a +// pid. Runs on the tart-backed Unix CLI targets where the test worker can SSH +// into the guest that globalsetup provisioned. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +const execFileAsync = promisify(execFile); +const PORT = 4789; + +const SSH_OPTS = [ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=8", + "-o", + "ServerAliveInterval=5", + "-o", + "LogLevel=ERROR", +] as const; + +const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { + const host = process.env.E2E_CLI_VM_HOST; + const os = process.env.E2E_VM_OS; + if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); + const wrapped = + os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; + try { + const { stdout, stderr } = await execFileAsync( + process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", + ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${host}`, wrapped], + { maxBuffer: 32 * 1024 * 1024 }, + ); + return { stdout, stderr, code: 0 }; + } catch (error) { + const err = error as { stdout?: string; stderr?: string; code?: number }; + return { + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + code: typeof err.code === "number" ? err.code : 1, + }; + } +}; + +const waitForGuestHealth = async (expected: boolean): Promise => { + const deadline = Date.now() + 30_000; + for (;;) { + const result = await ssh( + `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`, + ); + const healthy = result.stdout.trim() === "200"; + if (healthy === expected) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 500)); + } +}; + +const listenerPid = async (): Promise => + (await ssh(`lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`)).stdout.trim(); + +if (process.env.E2E_VM_OS === "windows") { + it.skip("CLI service install takeover · Windows coverage uses the restart service matrix", () => {}); +} else { + scenario( + "CLI service install · takes over a running predecessor daemon", + { timeout: 180_000 }, + Effect.promise(async () => { + const exe = `${process.env.E2E_CLI_BIN_DIR ?? "~/ed"}/executor`; + try { + await ssh(`${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`); + expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe( + true, + ); + + await ssh( + `nohup ${exe} daemon run --foreground --port ${PORT} >/tmp/takeover-predecessor.log 2>&1 &`, + ); + expect(await waitForGuestHealth(true), "predecessor daemon became reachable").toBe(true); + const predecessorPid = await listenerPid(); + expect(predecessorPid, "predecessor owns the service port").not.toBe(""); + + const install = await ssh(`${exe} service install --port ${PORT}`); + expect( + install.code, + `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`, + ).toBe(0); + expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true); + + const ownerPid = await listenerPid(); + const predecessorAlive = ( + await ssh(`kill -0 ${predecessorPid} 2>/dev/null && echo alive || echo dead`) + ).stdout.trim(); + expect(predecessorAlive, "predecessor process was stopped").toBe("dead"); + expect(ownerPid, "the service now owns the port").not.toBe(""); + expect(ownerPid, "the service is a different process").not.toBe(predecessorPid); + } finally { + await ssh(`${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`); + } + }), + ); +} diff --git a/e2e/desktop-packaged/supervised-attach.test.ts b/e2e/desktop-packaged/supervised-attach.test.ts index bee105846..c444fd2b7 100644 --- a/e2e/desktop-packaged/supervised-attach.test.ts +++ b/e2e/desktop-packaged/supervised-attach.test.ts @@ -4,24 +4,22 @@ // ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the // attach behavior can ONLY be proven against the packaged artifact. // -// We start the daemon as the bundle's OWN compiled `executor-sidecar` (the exact -// binary a supervised install runs) in EXECUTOR_SUPERVISED mode → it self- -// publishes a manifest of kind "cli-daemon". Then we launch the packaged app +// We start the daemon as the bundle's OWN compiled `executor` binary (the exact +// binary a supervised install runs) in EXECUTOR_SUPERVISED mode. It publishes a +// manifest of kind "cli-daemon". Then we launch the packaged app // pointed at the same HOME and prove it attached: the manifest still names the // daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a // fresh pid), and the console — served by the bearer-gated daemon — renders, // which only happens if the app injected the bearer it read from the manifest. // The recording (session.mp4 + screenshots) is the artifact; the waits assert. -import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { promisify } from "node:util"; +import { join } from "node:path"; import { expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { _electron } from "playwright"; import { scenario } from "../src/scenario"; import { RunDir } from "../src/services"; @@ -50,10 +48,138 @@ const guiAvailable = (): boolean => { const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; const appExe = process.env.E2E_DESKTOP_APP_EXE; -const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; -// The bundled web UI sits beside the sidecar in Resources/ (…/sidecar/ → -// …/web-ui). The compiled sidecar serves it via EXECUTOR_CLIENT_DIR. -const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; +const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; + +interface PackagedApp { + readonly child: ChildProcess; + readonly debugPort: string; + cdp: CdpPage; +} + +interface CdpResponse { + readonly id: number; + readonly result?: T; + readonly error?: { readonly message?: string }; +} + +interface CdpEvaluateResult { + readonly result: { readonly value?: unknown }; + readonly exceptionDetails?: unknown; +} + +interface CdpTarget { + readonly type: string; + readonly url: string; + readonly webSocketDebuggerUrl?: string; +} + +class CdpPage { + private nextId = 1; + private readonly pending = new Map< + number, + { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + } + >(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", (event) => { + const data = event.data; + if (typeof data !== "string") return; + const message = JSON.parse(data) as CdpResponse; + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? "CDP command failed")); + return; + } + pending.resolve(message.result); + }); + socket.addEventListener("close", () => { + for (const [, pending] of this.pending) { + pending.reject(new Error("CDP socket closed")); + } + this.pending.clear(); + }); + } + + static connect = (url: string): Promise => + new Promise((resolve, reject) => { + const socket = new WebSocket(url); + const timer = setTimeout(() => { + socket.close(); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter + reject(new Error(`Timed out connecting to page CDP target ${url}`)); + }, 30_000); + socket.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(new CdpPage(socket)); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timer); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter + reject(new Error(`Failed to connect to page CDP target ${url}`)); + }, + { once: true }, + ); + }); + + command = async (method: string, params: Record = {}): Promise => { + const id = this.nextId; + this.nextId += 1; + const result = new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + }); + this.socket.send(JSON.stringify({ id, method, params })); + return result; + }; + + evaluate = async (expression: string): Promise => { + const result = await this.command("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); + } + return result.result.value as T; + }; + + waitForText = async (text: string, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`; + for (;;) { + if (await this.evaluate(expression).catch(() => false)) return; + if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }; + + screenshot = async (path: string): Promise => { + const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + writeFileSync(path, Buffer.from(result.data, "base64")); + }; + + close = (): void => { + this.socket.close(); + }; +} const freePort = (): Promise => new Promise((resolve, reject) => { @@ -76,16 +202,20 @@ interface DaemonStart { readonly stderr: string; } -/** Spawn the bundle's compiled sidecar as a supervised daemon; resolves once it - * announces EXECUTOR_READY (or times out / exits early, ready:false). */ -const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => +/** Spawn the bundle's compiled executor as a supervised daemon; resolves once it + * announces readiness (or times out / exits early, ready:false). */ +const startSupervisedDaemon = (env: NodeJS.ProcessEnv, port: number): Promise => new Promise((resolve) => { - const child = spawn(sidecarBin as string, [], { env, stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn( + executorBin as string, + ["daemon", "run", "--foreground", "--port", String(port), "--hostname", "127.0.0.1"], + { env, stdio: ["ignore", "pipe", "pipe"] }, + ); let stderr = ""; const settle = (ready: boolean) => resolve({ child, ready, stderr }); const timer = setTimeout(() => settle(false), 60_000); child.stdout.on("data", (chunk: Buffer) => { - if (chunk.toString().includes("EXECUTOR_READY:")) { + if (/Daemon ready on http:\/\//.test(chunk.toString())) { clearTimeout(timer); settle(true); } @@ -99,16 +229,117 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => }); }); -if (!guiAvailable()) { - it.skip(`${SCENARIO_NAME} (needs a GUI display — Aqua / X / Wayland)`, () => {}); +const packagedSingleInstanceAvailable = (): boolean => { + if (process.platform !== "darwin" || !appExe) return true; + try { + const lines = execFileSync("pgrep", ["-fl", "Executor.app/Contents/MacOS/Executor"], { + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); + return !lines.some((line) => !line.includes(appExe)); + } catch { + return true; + } +}; + +const waitForPageWebSocket = async (debugPort: string): Promise => { + const deadline = Date.now() + 120_000; + for (;;) { + const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`) + .then((response) => (response.ok ? response.json() : [])) + .catch(() => [])) as ReadonlyArray; + const page = targets.find( + (target) => + target.type === "page" && + target.webSocketDebuggerUrl && + !target.url.startsWith("devtools://"), + ); + if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for packaged app page CDP target"); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +}; + +const launchPackaged = async (home: string): Promise => { + let output = ""; + let settled = false; + const child = spawn(appExe as string, ["--remote-debugging-port=0"], { + env: { ...process.env, HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const browserCdpUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter + reject(new Error(`Timed out waiting for packaged app CDP URL\n${output}`)); + }, 120_000); + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const collectOutput = (chunk: Buffer) => { + const text = chunk.toString(); + output = (output + text).slice(-16_384); + const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) settle(() => resolve(match[1]!)); + }; + child.stdout?.on("data", collectOutput); + child.stderr?.on("data", collectOutput); + // oxlint-disable-next-line executor/no-promise-reject -- boundary: packaged-app launch promise adapter + child.once("error", (error) => settle(() => reject(error))); + child.once("exit", (code, signal) => + settle(() => + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter + reject( + new Error(`Packaged app exited before CDP (code=${code} signal=${signal})\n${output}`), + ), + ), + ); + }); + + const debugPort = new URL(browserCdpUrl).port; + const pageCdpUrl = await waitForPageWebSocket(debugPort); + const cdp = await CdpPage.connect(pageCdpUrl); + await cdp.command("Runtime.enable"); + await cdp.command("Page.enable"); + return { child, cdp, debugPort }; +}; + +const stopProcess = async (child: ChildProcess | undefined): Promise => { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + child.kill("SIGTERM"); + }); +}; + +const closePackaged = async (app: PackagedApp | undefined): Promise => { + app?.cdp.close(); + await stopProcess(app?.child); +}; + +if (!guiAvailable() || !packagedSingleInstanceAvailable()) { + it.skip(`${SCENARIO_NAME} (needs a GUI display and no already-running Executor.app)`, () => {}); } else { scenario( SCENARIO_NAME, { timeout: 240_000 }, Effect.gen(function* () { - if (!appExe || !sidecarBin) { + if (!appExe || !executorBin) { return yield* Effect.die( - "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", + "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?", ); } const runDir = yield* RunDir; @@ -121,58 +352,50 @@ const run = async (runDir: string) => { const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-")); const dataDir = join(home, ".executor"); const manifestPath = join(dataDir, "server-control", "server.json"); - const videoTmp = join(runDir, ".video-tmp"); const port = await freePort(); let daemon: ChildProcess | undefined; - let app: Awaited> | undefined; + let app: PackagedApp | undefined; let stepIndex = 0; try { - const started = await startSupervisedDaemon({ - ...process.env, - HOME: home, - EXECUTOR_SUPERVISED: "1", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_PORT: String(port), - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_TOKEN: "packaged-attach-film", - EXECUTOR_CLIENT_DIR: clientDir, - }); + const started = await startSupervisedDaemon( + { + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_AUTH_TOKEN: "packaged-attach-film", + EXECUTOR_CLIENT: "desktop", + }, + port, + ); daemon = started.child; expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; - expect(daemonManifest.kind, "the compiled sidecar advertises itself as cli-daemon").toBe( + expect(daemonManifest.kind, "the bundled executor advertises itself as cli-daemon").toBe( "cli-daemon", ); const daemonPid = daemonManifest.pid; - // Launch the PACKAGED bundle (executablePath = the installed app binary, no - // app-dir arg) → app.isPackaged is true → boot() runs the supervised attach. - app = await _electron.launch({ - executablePath: appExe as string, - env: { ...process.env, HOME: home }, - recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, - timeout: 120_000, - }); - - const page = await app.firstWindow({ timeout: 120_000 }); + // Launch the PACKAGED bundle directly. `app.isPackaged` is true, so boot() + // runs the supervised attach path; CDP drives the real renderer. + app = await launchPackaged(home); + const page = app.cdp; const step = async (label: string, body: () => Promise) => { await body(); stepIndex += 1; const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - await page.screenshot({ - path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), - }); + await page.screenshot(join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`)); }; // The console only renders once the app has a live connection AND the bearer // it injects is accepted by the gated daemon — so reaching it proves both the // attach and the bearer wiring through the packaged session layer. await step("packaged app boots into the bearer-gated console", async () => { - await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + await page.waitForText("Settings", 120_000); }); // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still @@ -185,30 +408,8 @@ const run = async (runDir: string) => { ); }); } finally { - const page = app?.windows()[0]; - const video = page?.video(); - await app?.close().catch(() => {}); - const recordedPath = await video?.path().catch(() => undefined); - if (recordedPath && existsSync(recordedPath)) { - await promisify(execFile)("ffmpeg", [ - "-y", - "-i", - recordedPath, - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "26", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - join(runDir, "session.mp4"), - ]).catch(() => {}); - } - daemon?.kill("SIGTERM"); - rmSync(videoTmp, { recursive: true, force: true }); + await closePackaged(app); + await stopProcess(daemon); rmSync(home, { recursive: true, force: true }); } }; diff --git a/e2e/desktop-packaged/supervised-regressions.test.ts b/e2e/desktop-packaged/supervised-regressions.test.ts index 88bbc619c..abfce897f 100644 --- a/e2e/desktop-packaged/supervised-regressions.test.ts +++ b/e2e/desktop-packaged/supervised-regressions.test.ts @@ -1,17 +1,25 @@ // Packaged desktop supervised-daemon regressions. These run against the real -// electron-builder bundle and its compiled sidecar because the supervised attach +// electron-builder bundle and its bundled executor because the supervised attach // path is production-only (`app.isPackaged`). import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { createServer, type IncomingMessage } from "node:http"; import net from "node:net"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { _electron, type ElectronApplication } from "playwright"; import { normalizeExecutorServerConnection, serializeExecutorLocalServerManifest, @@ -21,6 +29,9 @@ import { scenario } from "../src/scenario"; import { RunDir } from "../src/services"; import { waitForHttp } from "../setup/boot"; +const execFileAsync = promisify(execFile); +const SERVICE_LABEL = "sh.executor.daemon"; + interface PackagedExecutorBridge { readonly getSettings: () => Promise<{ readonly port: number }>; readonly updateSettings: (patch: { readonly port: number }) => Promise; @@ -28,6 +39,173 @@ interface PackagedExecutorBridge { readonly getServerConnection: () => Promise<{ readonly origin: string } | null>; } +interface PackagedApp { + readonly child: ChildProcess; + cdp: CdpPage; + readonly debugPort: string; + readonly output: () => string; +} + +interface CdpResponse { + readonly id: number; + readonly result?: T; + readonly error?: { readonly message?: string; readonly data?: string }; +} + +interface CdpEvaluateResult { + readonly result: { readonly value?: unknown }; + readonly exceptionDetails?: unknown; +} + +interface CdpTarget { + readonly type: string; + readonly url: string; + readonly webSocketDebuggerUrl?: string; +} + +class CdpPage { + private nextId = 1; + private readonly pending = new Map< + number, + { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + } + >(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", (event) => { + const data = event.data; + if (typeof data !== "string") return; + const message = JSON.parse(data) as CdpResponse; + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? "CDP command failed")); + return; + } + pending.resolve(message.result); + }); + socket.addEventListener("close", () => { + for (const [, pending] of this.pending) { + pending.reject(new Error("CDP socket closed")); + } + this.pending.clear(); + }); + } + + static connect = (url: string): Promise => + new Promise((resolve, reject) => { + const socket = new WebSocket(url); + const timer = setTimeout(() => { + socket.close(); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter + reject(new Error(`Timed out connecting to page CDP target ${url}`)); + }, 30_000); + socket.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(new CdpPage(socket)); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timer); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: WebSocket connection promise adapter + reject(new Error(`Failed to connect to page CDP target ${url}`)); + }, + { once: true }, + ); + }); + + command = async (method: string, params: Record = {}): Promise => { + const id = this.nextId; + this.nextId += 1; + const result = new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + }); + }); + this.socket.send(JSON.stringify({ id, method, params })); + return result; + }; + + evaluate = async (expression: string): Promise => { + const result = await this.command("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error(`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); + } + return result.result.value as T; + }; + + waitForText = async (text: string, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + const expression = `document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`; + for (;;) { + if (await this.evaluate(expression).catch(() => false)) return; + if (Date.now() >= deadline) throw new Error(`Timed out waiting for text: ${text}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }; + + waitForExpression = async ( + expression: string, + timeoutMs: number, + description: string, + ): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await this.evaluate(`Boolean(${expression})`).catch(() => false)) return; + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }; + + textPresent = async (text: string): Promise => + this.evaluate(`document.body?.innerText.includes(${JSON.stringify(text)}) ?? false`); + + setViewport = async (width: number, height: number): Promise => { + await this.command("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); + }; + + wheel = async (x: number, y: number, deltaY: number): Promise => { + await this.command("Input.dispatchMouseEvent", { + type: "mouseWheel", + x, + y, + deltaX: 0, + deltaY, + }); + }; + + screenshot = async (path: string): Promise => { + const result = await this.command<{ readonly data: string }>("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + writeFileSync(path, Buffer.from(result.data, "base64")); + }; + + close = (): void => { + this.socket.close(); + }; +} + declare global { interface Window { readonly executor: PackagedExecutorBridge; @@ -35,8 +213,7 @@ declare global { } const appExe = process.env.E2E_DESKTOP_APP_EXE; -const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; -const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; +const executorBin = process.env.E2E_DESKTOP_EXECUTOR_BIN; const guiAvailable = (): boolean => { if (process.platform === "darwin") { @@ -65,13 +242,102 @@ const packagedSingleInstanceAvailable = (): boolean => { } }; -const requireBundle = (): { readonly app: string; readonly sidecar: string } => { - if (!appExe || !sidecarBin) { +const requireBundle = (): { readonly app: string; readonly executor: string } => { + if (!appExe || !executorBin) { throw new Error( - "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", + "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_EXECUTOR_BIN not set — did desktop-packaged.globalsetup run?", ); } - return { app: appExe, sidecar: sidecarBin }; + return { app: appExe, executor: executorBin }; +}; + +const currentUid = (): number => { + const getuid = (process as { readonly getuid?: () => number }).getuid; + return typeof getuid === "function" ? getuid.call(process) : 0; +}; + +const serviceTarget = (): string => `gui/${currentUid()}/${SERVICE_LABEL}`; +const launchAgentPath = (): string => + join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`); +const isolatedDesktopSettingsDir = (home: string): string => + join(home, ".executor-desktop-settings"); +const desktopSettingsDirs = (home: string): readonly string[] => { + if (process.platform === "darwin") { + const support = join(home, "Library", "Application Support"); + return [ + isolatedDesktopSettingsDir(home), + join(support, "@executor-js", "desktop"), + join(support, "Executor"), + ]; + } + if (process.platform === "linux") { + return [ + isolatedDesktopSettingsDir(home), + join(home, ".config", "@executor-js", "desktop"), + join(home, ".config", "Executor"), + ]; + } + const roaming = join(home, "AppData", "Roaming"); + return [ + isolatedDesktopSettingsDir(home), + join(roaming, "@executor-js", "desktop"), + join(roaming, "Executor"), + ]; +}; + +const packagedAppEnv = (home: string): NodeJS.ProcessEnv => { + return { + ...process.env, + HOME: home, + EXECUTOR_DESKTOP_SETTINGS_DIR: isolatedDesktopSettingsDir(home), + }; +}; + +interface LaunchdServiceSnapshot { + readonly plist: string | null; + readonly wasLoaded: boolean; +} + +const launchctl = async (args: ReadonlyArray): Promise => { + try { + await execFileAsync("launchctl", [...args]); + return true; + } catch { + return false; + } +}; + +const captureLaunchdService = (): LaunchdServiceSnapshot | null => { + if (process.platform !== "darwin") return null; + const path = launchAgentPath(); + const plist = existsSync(path) ? readFileSync(path, "utf8") : null; + let wasLoaded = false; + try { + execFileSync("launchctl", ["print", serviceTarget()], { stdio: "ignore" }); + wasLoaded = true; + } catch { + wasLoaded = false; + } + return { plist, wasLoaded }; +}; + +const restoreLaunchdService = async (snapshot: LaunchdServiceSnapshot | null): Promise => { + if (!snapshot) return; + const target = serviceTarget(); + await launchctl(["bootout", target]); + const path = launchAgentPath(); + if (snapshot.plist === null) { + rmSync(path, { force: true }); + return; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, snapshot.plist, { mode: 0o600 }); + chmodSync(path, 0o600); + await launchctl(["enable", target]); + if (snapshot.wasLoaded) { + const bootstrapped = await launchctl(["bootstrap", `gui/${currentUid()}`, path]); + if (bootstrapped) await launchctl(["kickstart", "-k", target]); + } }; const freePort = (): Promise => @@ -90,10 +356,18 @@ interface DaemonStart { readonly stderr: string; } -const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => +const startSupervisedDaemon = ( + env: NodeJS.ProcessEnv, + port: number, + hostname = "127.0.0.1", +): Promise => new Promise((resolve) => { - const { sidecar } = requireBundle(); - const child = spawn(sidecar, [], { env, stdio: ["ignore", "pipe", "pipe"] }); + const { executor } = requireBundle(); + const child = spawn( + executor, + ["daemon", "run", "--foreground", "--port", String(port), "--hostname", hostname], + { env, stdio: ["ignore", "pipe", "pipe"] }, + ); let stderr = ""; let settled = false; const settle = (ready: boolean) => { @@ -103,7 +377,7 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => }; const timer = setTimeout(() => settle(false), 60_000); child.stdout.on("data", (chunk: Buffer) => { - if (chunk.toString().includes("EXECUTOR_READY:")) { + if (/Daemon ready on http:\/\//.test(chunk.toString())) { clearTimeout(timer); settle(true); } @@ -117,34 +391,228 @@ const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => }); }); -const closeWithVideo = async ( - app: ElectronApplication | undefined, - runDir: string, - videoTmp: string, -) => { - const page = app?.windows()[0]; - const video = page?.video(); - await app?.close().catch(() => {}); - const recordedPath = await video?.path().catch(() => undefined); - if (recordedPath) { - await promisify(execFile)("ffmpeg", [ - "-y", - "-i", - recordedPath, - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "26", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - join(runDir, "session.mp4"), - ]).catch(() => {}); +const stopProcess = async (child: ChildProcess | undefined): Promise => { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + child.kill("SIGTERM"); + }); +}; + +const waitForPageWebSocket = async (debugPort: string): Promise => { + const deadline = Date.now() + 120_000; + for (;;) { + const targets = (await fetch(`http://127.0.0.1:${debugPort}/json/list`) + .then((response) => (response.ok ? response.json() : [])) + .catch(() => [])) as ReadonlyArray; + const page = targets.find( + (target) => + target.type === "page" && + target.webSocketDebuggerUrl && + !target.url.startsWith("devtools://"), + ); + if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for packaged app page CDP target"); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +}; + +const launchPackaged = async (home: string): Promise => { + const { app } = requireBundle(); + let output = ""; + let settled = false; + const child = spawn(app, ["--remote-debugging-port=0"], { + env: packagedAppEnv(home), + stdio: ["ignore", "pipe", "pipe"], + }); + + const browserCdpUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter + reject(new Error(`Timed out waiting for packaged app CDP URL\n${output}`)); + }, 120_000); + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const collectOutput = (chunk: Buffer) => { + const text = chunk.toString(); + output = (output + text).slice(-16_384); + const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) settle(() => resolve(match[1])); + }; + child.stdout?.on("data", collectOutput); + child.stderr?.on("data", collectOutput); + // oxlint-disable-next-line executor/no-promise-reject -- boundary: packaged-app launch promise adapter + child.once("error", (error) => settle(() => reject(error))); + child.once("exit", (code, signal) => + settle(() => + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: packaged-app launch promise adapter + reject( + new Error(`Packaged app exited before CDP (code=${code} signal=${signal})\n${output}`), + ), + ), + ); + }); + + const debugPort = new URL(browserCdpUrl).port; + const pageCdpUrl = await waitForPageWebSocket(debugPort); + const cdp = await CdpPage.connect(pageCdpUrl); + await cdp.command("Runtime.enable"); + await cdp.command("Page.enable"); + return { child, cdp, debugPort, output: () => output }; +}; + +const reconnectPackagedPage = async (app: PackagedApp): Promise => { + app.cdp.close(); + const pageCdpUrl = await waitForPageWebSocket(app.debugPort); + const cdp = await CdpPage.connect(pageCdpUrl); + await cdp.command("Runtime.enable"); + await cdp.command("Page.enable"); + app.cdp = cdp; + return cdp; +}; + +const closePackaged = async (app: PackagedApp | undefined): Promise => { + app?.cdp.close(); + await stopProcess(app?.child); +}; + +const waitUntil = async (predicate: () => boolean, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (predicate()) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +}; + +const waitForServerConnectionLabel = async ( + page: CdpPage, + expectedText: string, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + let label = ""; + for (;;) { + label = await page + .evaluate( + `document.querySelector('[aria-label^="Select Executor server:"]')?.getAttribute('aria-label') ?? ""`, + ) + .catch(() => ""); + if (label.includes(expectedText)) return label; + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for server connection label ${expectedText}; last=${label}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } +}; + +const settingsScrollFrameExpression = `(() => { + const frames = Array.from(document.querySelectorAll("div")); + const frame = frames.find((el) => { + const style = getComputedStyle(el); + const text = el.textContent ?? ""; + return style.overflowY === "auto" && + el.scrollHeight > el.clientHeight && + text.includes("Desktop server connection") && + text.includes("CLI profile") && + text.includes("Bearer token"); + }); + if (!frame) return null; + return { + scrollTop: frame.scrollTop, + scrollHeight: frame.scrollHeight, + clientHeight: frame.clientHeight, + }; +})()`; + +const assertDesktopSettingsScrolls = async (page: CdpPage): Promise => { + await page.setViewport(900, 420); + await page.waitForExpression( + `${settingsScrollFrameExpression} !== null`, + 30_000, + "the desktop settings scroll frame", + ); + const before = await page.evaluate<{ + readonly scrollTop: number; + readonly scrollHeight: number; + readonly clientHeight: number; + }>(settingsScrollFrameExpression); + expect( + before.scrollHeight, + "the settings page should have overflow content in a short desktop window", + ).toBeGreaterThan(before.clientHeight); + + await page.wheel(450, 220, 640); + await page.waitForExpression( + `${settingsScrollFrameExpression}?.scrollTop > ${before.scrollTop}`, + 30_000, + "desktop settings to scroll after a wheel gesture", + ); + const after = await page.evaluate<{ + readonly scrollTop: number; + readonly scrollHeight: number; + readonly clientHeight: number; + }>(settingsScrollFrameExpression); + expect(after.scrollTop, "wheel scrolling should move the settings page").toBeGreaterThan( + before.scrollTop, + ); +}; + +const openDesktopSettings = async (page: CdpPage): Promise => { + const clicked = await page.evaluate(`(() => { + const link = document.querySelector('a[href*="desktop-settings"]'); + if (!(link instanceof HTMLAnchorElement)) return false; + link.click(); + return true; + })()`); + expect(clicked, "the packaged desktop app should expose a Settings nav link").toBe(true); + await page.waitForText("Desktop server connection", 30_000); +}; + +const writeStaleActiveServerProfile = (input: { + readonly home: string; + readonly port: number; +}): void => { + const staleOrigin = `http://127.0.0.1:${input.port}`; + const staleKey = `http:${staleOrigin}`; + const settings = `${JSON.stringify( + { + server: { port: input.port }, + serverProfiles: JSON.stringify({ + version: 1, + activeKey: staleKey, + profiles: [ + { + kind: "http", + origin: staleOrigin, + displayName: "Stale Basic daemon", + auth: { kind: "basic", username: "executor", password: "wrong-password" }, + }, + ], + }), + }, + null, + 2, + )}\n`; + for (const settingsDir of new Set(desktopSettingsDirs(input.home))) { + mkdirSync(settingsDir, { recursive: true }); + writeFileSync(join(settingsDir, "settings.json"), settings, { mode: 0o600 }); } - rmSync(videoTmp, { recursive: true, force: true }); }; scenario( @@ -159,16 +627,17 @@ scenario( let daemon: ChildProcess | undefined; const previousUmask = process.umask(0o022); try { - const started = await startSupervisedDaemon({ - ...process.env, - HOME: home, - EXECUTOR_SUPERVISED: "1", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_PORT: String(port), - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_TOKEN: "manifest-mode-token", - EXECUTOR_CLIENT_DIR: clientDir, - }); + const started = await startSupervisedDaemon( + { + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_AUTH_TOKEN: "manifest-mode-token", + EXECUTOR_CLIENT: "desktop", + }, + port, + ); daemon = started.child; expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe( true, @@ -194,10 +663,7 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { scenario( "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", { timeout: 240_000 }, - Effect.gen(function* () { - const runDir = yield* RunDir; - yield* Effect.promise(() => runStaleManifestProbe(runDir)); - }), + Effect.promise(() => runStaleManifestProbe()), ); scenario( @@ -208,24 +674,24 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { yield* Effect.promise(() => runSupervisedPortSetting(runDir)); }), ); -} -const launchPackaged = (home: string, videoTmp: string): Promise => { - const { app } = requireBundle(); - return _electron.launch({ - executablePath: app, - env: { ...process.env, HOME: home }, - recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, - timeout: 120_000, - }); -}; + scenario( + "Desktop packaged supervised attach · integrations load through the CLI daemon with stale profiles", + { timeout: 240_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => runSupervisedIntegrationsLoad(runDir)); + }), + ); +} -const runStaleManifestProbe = async (runDir: string) => { +const runStaleManifestProbe = async () => { const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); const dataDir = join(home, ".executor"); const controlDir = join(dataDir, "server-control"); - const videoTmp = join(runDir, ".video-tmp"); + const manifestPath = join(controlDir, "server.json"); const token = "stale-manifest-leaked-token"; + const launchdSnapshot = captureLaunchdService(); const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; let resolveFirst!: () => void; const firstRequest = new Promise((resolve) => { @@ -238,11 +704,12 @@ const runStaleManifestProbe = async (runDir: string) => { }); resolveFirst(); res.writeHead(200, { "content-type": "text/html" }); - res.end("fake daemonfake daemon"); + res.end("stale daemonstale daemon"); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as net.AddressInfo).port; - let app: ElectronApplication | undefined; + let appProcess: ChildProcess | undefined; + let appOutput = ""; try { mkdirSync(controlDir, { recursive: true }); @@ -265,19 +732,35 @@ const runStaleManifestProbe = async (runDir: string) => { { mode: 0o600 }, ); - app = await launchPackaged(home, videoTmp); + appProcess = spawn(requireBundle().app, [], { + env: packagedAppEnv(home), + stdio: ["ignore", "pipe", "pipe"], + }); + const collectOutput = (chunk: Buffer) => { + appOutput = (appOutput + chunk.toString()).slice(-8_192); + }; + appProcess.stdout?.on("data", collectOutput); + appProcess.stderr?.on("data", collectOutput); + const probed = await Promise.race([ firstRequest.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 60_000)), ]); - expect(probed, "packaged app probed the stale manifest endpoint").toBe(true); + expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true); expect( requests[0]?.authorization ?? null, "the stale-manifest reachability probe must not disclose the saved bearer", ).toBeNull(); + + const manifestRemoved = await waitUntil(() => !existsSync(manifestPath), 15_000); + expect( + manifestRemoved, + "a live pid with a failed health probe must be removed before desktop falls back", + ).toBe(true); } finally { - await closeWithVideo(app, runDir, videoTmp); + await stopProcess(appProcess); + await restoreLaunchdService(launchdSnapshot); await new Promise((resolve) => server.close(() => resolve())); rmSync(home, { recursive: true, force: true }); } @@ -286,64 +769,166 @@ const runStaleManifestProbe = async (runDir: string) => { const runSupervisedPortSetting = async (runDir: string) => { const home = mkdtempSync(join(tmpdir(), "executor-pkg-port-setting-")); const dataDir = join(home, ".executor"); - const videoTmp = join(runDir, ".video-tmp"); + const launchdSnapshot = captureLaunchdService(); const oldPort = await freePort(); const newPort = await freePort(); let daemon: ChildProcess | undefined; - let app: ElectronApplication | undefined; + let app: PackagedApp | undefined; try { - const started = await startSupervisedDaemon({ - ...process.env, - HOME: home, - EXECUTOR_SUPERVISED: "1", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_PORT: String(oldPort), - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_TOKEN: "port-setting-token", - EXECUTOR_CLIENT_DIR: clientDir, - }); + const started = await startSupervisedDaemon( + { + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_AUTH_TOKEN: "port-setting-token", + EXECUTOR_CLIENT: "desktop", + }, + oldPort, + ); daemon = started.child; expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); await waitForHttp(`http://127.0.0.1:${oldPort}/`, { timeoutMs: 30_000 }); - app = await launchPackaged(home, videoTmp); - const page = await app.firstWindow({ timeout: 120_000 }); - await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + app = await launchPackaged(home); + let page = app.cdp; + await page.waitForText("Settings", 120_000); + await openDesktopSettings(page); + await assertDesktopSettingsScrolls(page); + await page.screenshot(join(runDir, "01-attached-settings.png")); - const before = await page.evaluate(async () => { - return window.executor.getServerConnection(); - }); + const before = await page.evaluate<{ readonly origin: string } | null>( + "window.executor.getServerConnection()", + ); expect(new URL(before!.origin).port, "test starts attached to the original port").toBe( String(oldPort), ); - await page.evaluate(async (port) => { - await window.executor.updateSettings({ port }); - }, newPort); + await page.evaluate(`window.executor.updateSettings({ port: ${JSON.stringify(newPort)} })`); await page - .evaluate(async () => { - await window.executor.restartServer(); - }) + .evaluate("window.executor.restartServer().catch(() => undefined)") .catch(() => undefined); - await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + page = await reconnectPackagedPage(app); + await page.waitForText("Settings", 120_000); - const after = await page.evaluate(async () => { - return { - settings: await window.executor.getSettings(), - connection: await window.executor.getServerConnection(), - }; - }); + const after = await page.evaluate<{ + readonly settings: { readonly port: number }; + readonly connection: { readonly origin: string } | null; + }>( + "(async () => ({ settings: await window.executor.getSettings(), connection: await window.executor.getServerConnection() }))()", + ); expect(after.settings.port, "the setting was persisted").toBe(newPort); expect( new URL(after.connection!.origin).port, "after restart, the active supervised daemon should be serving on the saved port", ).toBe(String(newPort)); + await page.screenshot(join(runDir, "02-restarted-on-new-port.png")); + } finally { + await closePackaged(app); + await stopProcess(daemon); + await restoreLaunchdService(launchdSnapshot); + rmSync(home, { recursive: true, force: true }); + } +}; + +const runSupervisedIntegrationsLoad = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-pkg-integrations-load-")); + const dataDir = join(home, ".executor"); + const launchdSnapshot = captureLaunchdService(); + const port = await freePort(); + let daemon: ChildProcess | undefined; + let app: PackagedApp | undefined; + + try { + writeStaleActiveServerProfile({ home, port }); + const started = await startSupervisedDaemon( + { + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_AUTH_TOKEN: "integrations-load-token", + EXECUTOR_CLIENT: "desktop", + }, + port, + "localhost", + ); + daemon = started.child; + expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); + await waitForHttp(`http://localhost:${port}/`, { timeoutMs: 30_000 }); + + const rootDocument = await fetch(`http://localhost:${port}/`); + expect( + rootDocument.headers.get("cache-control"), + "SPA boot document should not be cached", + ).toBe("no-store"); + await rootDocument.body?.cancel(); + const indexDocument = await fetch(`http://localhost:${port}/index.html`); + expect( + indexDocument.headers.get("cache-control"), + "direct index.html requests should not cache the SPA boot document", + ).toBe("no-store"); + await indexDocument.body?.cancel(); + + app = await launchPackaged(home); + const page = app.cdp; + + const serverLabel = await waitForServerConnectionLabel(page, "Local Executor", 120_000); + expect(serverLabel, "desktop must not auto-select a stale persisted server profile").toContain( + "Local Executor", + ); + await page.waitForExpression( + `document.querySelector('a[href$="/integrations/executor"]') !== null`, + 120_000, + "the built-in Executor integration link", + ); + const bootstrap = await page.evaluate<{ + readonly href: string; + readonly navigationName: string; + }>( + `(() => { + const navigation = performance.getEntriesByType("navigation")[0]; + return { + href: location.href, + navigationName: navigation?.name ?? "", + }; + })()`, + ); + expect( + bootstrap.navigationName, + "desktop should cache-bust each packaged renderer document load", + ).toContain("_executor_desktop_launch="); + expect( + bootstrap.navigationName, + "desktop should pass the daemon token during bootstrap", + ).toContain("_token="); + expect( + bootstrap.href, + "desktop should strip bootstrap cache-bust params after load", + ).not.toContain("_executor_desktop_launch="); + expect(bootstrap.href, "desktop should strip bootstrap token params after load").not.toContain( + "_token=", + ); + await page.screenshot(join(runDir, "01-integrations-loaded.png")); + expect( + await page.textPresent("Failed to load integrations").then((present) => (present ? 1 : 0)), + "integrations should render from the attached daemon, not a cached 401/500 failure", + ).toBe(0); + + const connection = await page.evaluate<{ readonly origin: string } | null>( + "window.executor.getServerConnection()", + ); + expect( + new URL(connection!.origin).port, + "the packaged app is rendering data from the supervised daemon", + ).toBe(String(port)); } finally { - await closeWithVideo(app, runDir, videoTmp); - daemon?.kill("SIGTERM"); + await closePackaged(app); + await stopProcess(daemon); + await restoreLaunchdService(launchdSnapshot); rmSync(home, { recursive: true, force: true }); } }; diff --git a/e2e/setup/desktop-packaged.globalsetup.ts b/e2e/setup/desktop-packaged.globalsetup.ts index a7568e44d..14a0f3b7a 100644 --- a/e2e/setup/desktop-packaged.globalsetup.ts +++ b/e2e/setup/desktop-packaged.globalsetup.ts @@ -2,12 +2,12 @@ // dev electron) so the scenarios drive the production artifact — the only place // app.isPackaged is true, which is what gates the supervised-daemon attach path // (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled -// compiled sidecar (executor-sidecar + extraResources). The dev-electron desktop +// bundled executor CLI binary. The dev-electron desktop // project can't reach any of that. // -// Builds web UI → compiled sidecar → electron-vite main/preload → electron-builder +// Builds bundled executor → electron-vite main/preload → electron-builder // (unsigned e2e config, `dir` target = the unpacked .app/.exe, no DMG/notarize). -// Publishes the launch exe + the bundled sidecar path via env for the workers. +// Publishes the launch exe + the bundled executor path via env for the workers. // // Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to // reuse an existing dist/ bundle while iterating. @@ -16,24 +16,23 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; -const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); -// (launch exe, bundled sidecar binary) inside the packaged bundle, per platform. -const bundlePaths = (): { exe: string; sidecar: string } => { +// (launch exe, bundled executor binary) inside the packaged bundle, per platform. +const bundlePaths = (): { exe: string; executor: string } => { const arch = process.arch; // arm64 | x64 if (process.platform === "darwin") { const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app"); return { exe: join(app, "Contents/MacOS/Executor"), - sidecar: join(app, "Contents/Resources/sidecar/executor-sidecar"), + executor: join(app, "Contents/Resources/executor/executor"), }; } if (process.platform === "win32") { const dir = join(appDir, "dist", "win-unpacked"); return { exe: join(dir, "Executor.exe"), - sidecar: join(dir, "resources/sidecar/executor-sidecar.exe"), + executor: join(dir, "resources/executor/executor.exe"), }; } // electron-builder names the dir `linux-unpacked` for x64 and @@ -41,7 +40,7 @@ const bundlePaths = (): { exe: string; sidecar: string } => { const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`); return { exe: join(dir, "executor-desktop"), - sidecar: join(dir, "resources/sidecar/executor-sidecar"), + executor: join(dir, "resources/executor/executor"), }; }; @@ -49,18 +48,16 @@ const builderFlag = process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux"; export default function setup() { - const { exe, sidecar } = bundlePaths(); + const { exe, executor } = bundlePaths(); if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) { const run = (cmd: string, args: string[], cwd: string) => execFileSync(cmd, args, { cwd, stdio: "inherit", env: { ...process.env } }); - // 1. web UI bundle (served by the sidecar; staged into the package). - run("bun", ["run", "--filter", "@executor-js/local", "build"], repoRoot); - // 2. compiled sidecar + native bindings → resources/sidecar. + // 1. compiled CLI + embedded web UI + native bindings → resources/executor. run("bun", ["./scripts/build-sidecar.ts"], appDir); - // 3. electron-vite main/preload → out/. + // 2. electron-vite main/preload → out/. run("bunx", ["--bun", "electron-vite", "build"], appDir); - // 4. electron-builder unsigned bundle (dir target). CSC_IDENTITY_AUTO_DISCOVERY + // 3. electron-builder unsigned bundle (dir target). CSC_IDENTITY_AUTO_DISCOVERY // off so it never reaches for a signing identity. execFileSync( "bunx", @@ -76,9 +73,9 @@ export default function setup() { if (!existsSync(exe)) { throw new Error(`packaged desktop exe not found at ${exe} after build`); } - if (!existsSync(sidecar)) { - throw new Error(`bundled sidecar not found at ${sidecar} after build`); + if (!existsSync(executor)) { + throw new Error(`bundled executor not found at ${executor} after build`); } process.env.E2E_DESKTOP_APP_EXE = exe; - process.env.E2E_DESKTOP_SIDECAR_BIN = sidecar; + process.env.E2E_DESKTOP_EXECUTOR_BIN = executor; } diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index d9e85d463..3200bab57 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -58,8 +58,8 @@ export default defineConfig({ }), // The PACKAGED desktop app: the real electron-builder bundle, where // app.isPackaged is true — the ONLY target that exercises the supervised- - // daemon attach path (ensureSupervisedConnection) and the bundled compiled - // sidecar. Its globalsetup builds the bundle (slow), so it's separate from + // daemon attach path (ensureSupervisedConnection) and the bundled executor. + // Its globalsetup builds the bundle (slow), so it's separate from // `desktop` to keep the fast dev-electron suite off the package build. // Needs a display; not part of the default `npm run test` chain — run with // `vitest run --project desktop-packaged`. diff --git a/packages/app/src/web/server-connection-menu.tsx b/packages/app/src/web/server-connection-menu.tsx index 0b9585825..ed4c46363 100644 --- a/packages/app/src/web/server-connection-menu.tsx +++ b/packages/app/src/web/server-connection-menu.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ServerIcon } from "lucide-react"; import { getExecutorServerAuthorizationHeader, + normalizeExecutorServerConnection, useExecutorServerConnection, useSetExecutorServerConnection, type ExecutorServerAuth, @@ -74,6 +75,16 @@ const desktopProfileStorageBridge = (): DesktopProfileStorageBridge | null => { }; }; +const hasDesktopServerConnectionBridge = (): boolean => + Boolean(desktopProfileStorageBridge()) || + typeof globalThis.window?.executor?.getServerConnection === "function"; + +const readDesktopServerConnection = (): Promise | null => { + const bridge = globalThis.window?.executor; + if (!bridge || typeof bridge.getServerConnection !== "function") return null; + return bridge.getServerConnection(); +}; + const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => readExecutorServerProfiles(browserStorage()); @@ -134,7 +145,7 @@ const serverDescription = (connection: ExecutorServerConnection): string => connection.origin.replace(/^https?:\/\//, ""); const serverKindLabel = (connection: ExecutorServerConnection): string => { - if (connection.kind === "desktop-sidecar") return "Desktop"; + if (connection.kind === "desktop-sidecar") return "Local"; const hostname = new URL(connection.origin).hostname; return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ? "Local" @@ -149,6 +160,29 @@ const authLabel = (connection: ExecutorServerConnection): string => { return "Auth"; }; +const isLoopbackHost = (host: string): boolean => + host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; + +const isLoopbackConnection = (connection: ExecutorServerConnection): boolean => { + const url = new URL(connection.origin); + return isLoopbackHost(url.hostname); +}; + +const sameLoopbackServer = (a: ExecutorServerConnection, b: ExecutorServerConnection): boolean => { + const left = new URL(a.origin); + const right = new URL(b.origin); + return ( + left.protocol === right.protocol && + isLoopbackHost(left.hostname) && + isLoopbackHost(right.hostname) && + (left.port || (left.protocol === "https:" ? "443" : "80")) === + (right.port || (right.protocol === "https:" ? "443" : "80")) + ); +}; + +const hasBearerAuth = (connection: ExecutorServerConnection): boolean => + connection.auth?.kind === "bearer" && connection.auth.token.length > 0; + const snapshotWithCurrent = ( snapshot: ExecutorServerProfilesSnapshot, connection: ExecutorServerConnection, @@ -156,6 +190,14 @@ const snapshotWithCurrent = ( ): ExecutorServerProfilesSnapshot => upsertExecutorServerProfile(snapshot, connection, { makeActive }) ?? snapshot; +const withoutLoopbackProfiles = ( + snapshot: ExecutorServerProfilesSnapshot, +): ExecutorServerProfilesSnapshot => + normalizeExecutorServerProfilesSnapshot({ + activeKey: snapshot.activeKey, + profiles: snapshot.profiles.filter((profile) => !isLoopbackConnection(profile)), + }); + const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => { const secret = draft.secret.trim(); if (draft.authMode === "bearer" && secret) { @@ -201,14 +243,34 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { let cancelled = false; void readStoredProfiles().then((stored) => { - if (cancelled) return; - const storedActive = getActiveExecutorServerProfile(stored); - const next = snapshotWithCurrent(stored, connection, storedActive === null); - persistSnapshot(next); - if (storedActive && storedActive.key !== connection.key) { - setServerConnection(storedActive); - } - setHydrated(true); + void (async () => { + if (cancelled) return; + const desktopBridge = hasDesktopServerConnectionBridge(); + const desktopConnection = + (await readDesktopServerConnection()?.then( + (value) => value, + () => null, + )) ?? null; + const storedActive = getActiveExecutorServerProfile(stored); + const current = desktopConnection + ? normalizeExecutorServerConnection(desktopConnection) + : connection; + const baseStored = desktopConnection ? withoutLoopbackProfiles(stored) : stored; + const shouldKeepCurrent = + desktopBridge || + storedActive === null || + (hasBearerAuth(current) && + storedActive !== null && + sameLoopbackServer(current, storedActive)); + const next = snapshotWithCurrent(baseStored, current, shouldKeepCurrent); + persistSnapshot(next); + if (desktopConnection) { + setServerConnection(desktopConnection); + } else if (!shouldKeepCurrent && storedActive && storedActive.key !== connection.key) { + setServerConnection(storedActive); + } + setHydrated(true); + })(); }); return () => { @@ -219,7 +281,10 @@ export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { useEffect(() => { if (!hydrated) return; setSnapshot((previous) => { - const next = snapshotWithCurrent(previous, connection, true); + const base = hasDesktopServerConnectionBridge() + ? withoutLoopbackProfiles(previous) + : previous; + const next = snapshotWithCurrent(base, connection, true); writeStoredProfiles(next); return next; }); diff --git a/packages/plugins/desktop-settings/src/client.tsx b/packages/plugins/desktop-settings/src/client.tsx index a377d6e40..3241cc4ac 100644 --- a/packages/plugins/desktop-settings/src/client.tsx +++ b/packages/plugins/desktop-settings/src/client.tsx @@ -13,7 +13,7 @@ * module-init time — so the web UI doesn't show a non-functional link. */ -import { useCallback, useEffect, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useState } from "react"; import { defineClientPlugin } from "@executor-js/sdk/client"; // --------------------------------------------------------------------------- @@ -72,6 +72,12 @@ const inDesktop = readBridge() !== null; const describeIpcError = (_err: unknown): string => "Save failed — check the desktop console for details."; +const pageFrameStyle: CSSProperties = { + minHeight: 0, + height: "100%", + overflowY: "auto", +}; + // --------------------------------------------------------------------------- // SettingsPage // --------------------------------------------------------------------------- @@ -168,17 +174,23 @@ function SettingsPage() { if (!bridge) { return ( -
-

Desktop server settings

-

- Open this page from Executor Desktop to inspect and change the active server connection. -

+
+
+

Desktop server settings

+

+ Open this page from Executor Desktop to inspect and change the active server connection. +

+
); } if (!settings || !draft || !connection) { - return
Loading…
; + return ( +
+
Loading…
+
+ ); } const dirty = draft.port !== settings.port; @@ -190,172 +202,102 @@ function SettingsPage() { : "executor tools sources --server desktop"; return ( -
-

- Desktop server connection -

-

- {connection.displayName} -

- -
- - - - -
+
+
+

+ Desktop server connection +

+

+ {connection.displayName} +

-
-
CLI profile
- - {cliProfileCommand} - - - {cliUseCommand} - -
+
+ + + + +
-
- +
+
CLI profile
+ + {cliProfileCommand} + + + {cliUseCommand} + +
-
- Bearer token -
- - {authToken ?? "—"} - +
+
- - The sidecar enforces this token on /api and /mcp. Rotating it - restarts the connection and invalidates existing MCP client configs — re-run your - connect command afterwards. - -
- -
- {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} - - {error && ( - {error} - )} -
- - {bridge.exportDiagnostics && ( -
-
Diagnostics
+ /> - Packs app and server logs, crash dumps, and version info into a zip in your Downloads - folder — attach it when reporting a bug. Your sources, secrets, and bearer token are - not included. + Changes restart the connection at http://127.0.0.1:{draft.port}. + + +
+ Bearer token
+ + {authToken ?? "—"} + {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} - {diagnostics.state === "done" && ( - - {diagnostics.path} - - )}
-
- )} + + The sidecar enforces this token on /api and /mcp. Rotating + it restarts the connection and invalidates existing MCP client configs — re-run your + connect command afterwards. + +
+ +
+ {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} + + {error && ( + {error} + )} +
+ + {bridge.exportDiagnostics && ( +
+
Diagnostics
+ + Packs app and server logs, crash dumps, and version info into a zip in your + Downloads folder — attach it when reporting a bug. Your sources, secrets, and bearer + token are not included. + +
+ {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */} + + {diagnostics.state === "done" && ( + + {diagnostics.path} + + )} +
+
+ )} +
); diff --git a/packages/react/src/api/local-auth.tsx b/packages/react/src/api/local-auth.tsx index f71efe576..fd7d1ee82 100644 --- a/packages/react/src/api/local-auth.tsx +++ b/packages/react/src/api/local-auth.tsx @@ -22,6 +22,7 @@ import * as React from "react"; import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection"; const STORAGE_KEY = "executor.authToken"; +const DESKTOP_LAUNCH_CACHE_BUST_PARAM = "_executor_desktop_launch"; const isDesktopBridge = (): boolean => typeof globalThis.window?.executor?.getServerConnection === "function"; @@ -57,10 +58,23 @@ const applyBearer = (token: string): void => { * localStorage. Identical in dev and prod. */ export const bootstrapLocalAuthToken = (): void => { - if (isDesktopBridge()) return; - const url = globalThis.window ? new URL(window.location.href) : null; const fromUrl = url?.searchParams.get("_token") ?? null; + const stripCacheBust = url?.searchParams.has(DESKTOP_LAUNCH_CACHE_BUST_PARAM) ?? false; + if (stripCacheBust) { + url!.searchParams.delete(DESKTOP_LAUNCH_CACHE_BUST_PARAM); + } + + if (isDesktopBridge()) { + if (fromUrl) { + url!.searchParams.delete("_token"); + } + if (stripCacheBust || fromUrl) { + globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); + } + return; + } + if (fromUrl) { persistToken(fromUrl); url!.searchParams.delete("_token"); @@ -69,6 +83,10 @@ export const bootstrapLocalAuthToken = (): void => { return; } + if (stripCacheBust) { + globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); + } + const stored = readStoredToken(); if (stored) applyBearer(stored); }; diff --git a/packages/react/src/api/server-connection.tsx b/packages/react/src/api/server-connection.tsx index db4a92dde..863db541e 100644 --- a/packages/react/src/api/server-connection.tsx +++ b/packages/react/src/api/server-connection.tsx @@ -127,6 +127,9 @@ interface ExecutorServerConnectionContextValue { const ExecutorServerConnectionContext = React.createContext(null); +const hasDesktopServerConnectionBridge = (): boolean => + typeof globalThis.window?.executor?.getServerConnection === "function"; + export function ExecutorServerConnectionProvider( props: React.PropsWithChildren<{ readonly connection?: ExecutorServerConnectionInput; @@ -142,6 +145,7 @@ export function ExecutorServerConnectionProvider( const [connection, setConnection] = React.useState(initialConnection); const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => { const next = normalizeExecutorServerConnection(input); + if (hasDesktopServerConnectionBridge() && next.kind !== "desktop-sidecar") return; activeConnection = next; setConnection(next); }, []); @@ -160,13 +164,13 @@ export function ExecutorServerConnectionProvider( if (typeof bridge?.getServerConnection !== "function") return; let cancelled = false; - const initialKey = activeConnection.key; void bridge.getServerConnection().then( (input) => { if (cancelled || !input) return; const next = normalizeExecutorServerConnection(input); - setConnection((current) => { - if (current.key !== initialKey) return current; + setConnection(() => { + // Electron loads the UI from a local URL before the async bridge + // answers. Once it does, the bridge is the authoritative app server. activeConnection = next; return next; }); From 1e79869e1f5b4098453850b892cb5fd67b4c4b7e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:23:27 -0700 Subject: [PATCH 6/6] Fix Windows service uninstall cleanup --- apps/cli/src/main.ts | 19 +- e2e/cli/service-install-takeover.test.ts | 216 +++++++++++++++++------ 2 files changed, 178 insertions(+), 57 deletions(-) diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 9174785f2..332791752 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -305,7 +305,9 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< ); }); -const takeOverActiveLocalServer = (): Effect.Effect< +const takeOverActiveLocalServer = (input?: { + readonly onlyKind?: ExecutorLocalServerKind; +}): Effect.Effect< ExecutorLocalServerManifest | null, Error, FileSystem.FileSystem | PlatformPath.Path @@ -313,6 +315,7 @@ const takeOverActiveLocalServer = (): Effect.Effect< Effect.gen(function* () { const manifest = yield* readLocalServerManifest(); if (!manifest) return null; + if (input?.onlyKind && manifest.kind !== input.onlyKind) return null; if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) { yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); @@ -2331,7 +2334,21 @@ const serviceInstallCommand = Command.make( const serviceUninstallCommand = Command.make("uninstall", {}, () => Effect.gen(function* () { const backend = getServiceBackend(); + const wasRunning = backend.automated + ? yield* backend.status().pipe( + Effect.map((status) => status.running), + Effect.catchCause(() => Effect.succeed(false)), + ) + : false; yield* backend.uninstall(); + if (wasRunning) { + const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); + if (stopped) { + console.log( + `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, + ); + } + } console.log("Executor background service uninstalled."); }), ).pipe(Command.withDescription("Stop and remove the OS-supervised background service")); diff --git a/e2e/cli/service-install-takeover.test.ts b/e2e/cli/service-install-takeover.test.ts index e14c6b1b2..5eff865c5 100644 --- a/e2e/cli/service-install-takeover.test.ts +++ b/e2e/cli/service-install-takeover.test.ts @@ -1,12 +1,12 @@ /* oxlint-disable executor/no-conditional-tests -- e2e scenario uses try/finally to restore the VM service after assertions */ // Real VM e2e for the upgrade path: `executor service install` must take over // a same-data-dir predecessor instead of refusing and leaving users to find a -// pid. Runs on the tart-backed Unix CLI targets where the test worker can SSH -// into the guest that globalsetup provisioned. -import { execFile } from "node:child_process"; +// pid. Runs on the CLI VM targets where the test worker can SSH into the guest +// that globalsetup provisioned. +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { promisify } from "node:util"; -import { expect, it } from "@effect/vitest"; +import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; @@ -27,18 +27,36 @@ const SSH_OPTS = [ "LogLevel=ERROR", ] as const; -const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { - const host = process.env.E2E_CLI_VM_HOST; +type GuestOs = "macos" | "linux" | "windows"; + +const guestOs = (): GuestOs => { const os = process.env.E2E_VM_OS; + if (os === "macos" || os === "linux" || os === "windows") return os; + throw new Error(`Unsupported E2E_VM_OS: ${os ?? ""}`); +}; + +const sshInvocation = (command: string): { command: string; args: ReadonlyArray } => { + const host = process.env.E2E_CLI_VM_HOST; if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); + const os = guestOs(); const wrapped = os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; + const keyPath = process.env.E2E_CLI_SSH_KEY; + const user = os === "windows" ? "Administrator" : "admin"; + return keyPath + ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] } + : { + command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", + args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped], + }; +}; + +const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { + const invocation = sshInvocation(command); try { - const { stdout, stderr } = await execFileAsync( - process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", - ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${host}`, wrapped], - { maxBuffer: 32 * 1024 * 1024 }, - ); + const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], { + maxBuffer: 32 * 1024 * 1024, + }); return { stdout, stderr, code: 0 }; } catch (error) { const err = error as { stdout?: string; stderr?: string; code?: number }; @@ -50,12 +68,20 @@ const ssh = async (command: string): Promise<{ stdout: string; stderr: string; c } }; +const executorPath = (): string => { + const dir = process.env.E2E_CLI_BIN_DIR ?? (guestOs() === "windows" ? "C:/ed" : "~/ed"); + return guestOs() === "windows" ? `${dir}/executor.exe` : `${dir}/executor`; +}; + +const healthStatusCommand = (): string => + guestOs() === "windows" + ? `try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 'http://127.0.0.1:${PORT}/api/health'; [string]$r.StatusCode } catch { '000' }` + : `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`; + const waitForGuestHealth = async (expected: boolean): Promise => { const deadline = Date.now() + 30_000; for (;;) { - const result = await ssh( - `curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${PORT}/api/health`, - ); + const result = await ssh(healthStatusCommand()); const healthy = result.stdout.trim() === "200"; if (healthy === expected) return true; if (Date.now() >= deadline) return false; @@ -64,46 +90,124 @@ const waitForGuestHealth = async (expected: boolean): Promise => { }; const listenerPid = async (): Promise => - (await ssh(`lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`)).stdout.trim(); - -if (process.env.E2E_VM_OS === "windows") { - it.skip("CLI service install takeover · Windows coverage uses the restart service matrix", () => {}); -} else { - scenario( - "CLI service install · takes over a running predecessor daemon", - { timeout: 180_000 }, - Effect.promise(async () => { - const exe = `${process.env.E2E_CLI_BIN_DIR ?? "~/ed"}/executor`; - try { - await ssh(`${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`); - expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe( - true, - ); - - await ssh( - `nohup ${exe} daemon run --foreground --port ${PORT} >/tmp/takeover-predecessor.log 2>&1 &`, - ); - expect(await waitForGuestHealth(true), "predecessor daemon became reachable").toBe(true); - const predecessorPid = await listenerPid(); - expect(predecessorPid, "predecessor owns the service port").not.toBe(""); - - const install = await ssh(`${exe} service install --port ${PORT}`); - expect( - install.code, - `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`, - ).toBe(0); - expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true); - - const ownerPid = await listenerPid(); - const predecessorAlive = ( - await ssh(`kill -0 ${predecessorPid} 2>/dev/null && echo alive || echo dead`) - ).stdout.trim(); - expect(predecessorAlive, "predecessor process was stopped").toBe("dead"); - expect(ownerPid, "the service now owns the port").not.toBe(""); - expect(ownerPid, "the service is a different process").not.toBe(predecessorPid); - } finally { - await ssh(`${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`); - } - }), - ); + ( + await ssh( + guestOs() === "windows" + ? `$c = Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $c) { [string]$c.OwningProcess }` + : `lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null | head -1`, + ) + ).stdout.trim(); + +const uninstallServiceCommand = (exe: string): string => + guestOs() === "windows" + ? `& '${exe}' service uninstall *> 'C:/Windows/Temp/takeover-uninstall.log'; exit 0` + : `${exe} service uninstall >/tmp/takeover-uninstall.log 2>&1 || true`; + +interface PredecessorHandle { + readonly close: () => void; + readonly diagnostics: () => Promise; } + +const appendChunk = (existing: string, chunk: Buffer): string => + `${existing}${chunk.toString("utf8")}`.slice(-16_000); + +const windowsPredecessorCommand = (exe: string): string => + `& '${exe}' daemon run --foreground --port ${PORT} --hostname 127.0.0.1`; + +const startPredecessor = async (exe: string): Promise => { + if (guestOs() !== "windows") { + await ssh( + `nohup ${exe} daemon run --foreground --port ${PORT} --hostname 127.0.0.1 >/tmp/takeover-predecessor.log 2>&1 &`, + ); + return { + close: () => {}, + diagnostics: async () => + (await ssh("cat /tmp/takeover-predecessor.log 2>/dev/null || true")).stdout.trim(), + }; + } + + const invocation = sshInvocation(windowsPredecessorCommand(exe)); + const child: ChildProcessWithoutNullStreams = spawn(invocation.command, [...invocation.args], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let exit: string | null = null; + child.stdout.on("data", (chunk: Buffer) => { + stdout = appendChunk(stdout, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr = appendChunk(stderr, chunk); + }); + child.on("error", (error) => { + exit = `spawn error: ${error.message}`; + }); + child.on("exit", (code, signal) => { + exit = `exit code ${code ?? ""} signal ${signal ?? ""}`; + }); + + return { + close: () => { + if (!child.killed) child.kill(); + }, + diagnostics: async () => + [`predecessor ssh: ${exit ?? "still running"}`, `stdout:\n${stdout}`, `stderr:\n${stderr}`] + .join("\n") + .trim(), + }; +}; + +const installServiceCommand = (exe: string): string => + guestOs() === "windows" + ? `& '${exe}' service install --port ${PORT}; exit $LASTEXITCODE` + : `${exe} service install --port ${PORT}`; + +const processAliveCommand = (pid: string): string => + guestOs() === "windows" + ? `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }` + : `kill -0 ${pid} 2>/dev/null && echo alive || echo dead`; + +const restoreServiceCommand = (exe: string): string => + guestOs() === "windows" + ? `& '${exe}' service install --port ${PORT} *> 'C:/Windows/Temp/takeover-restore.log'; exit 0` + : `${exe} service install --port ${PORT} >/tmp/takeover-restore.log 2>&1 || true`; + +scenario( + "CLI service install · takes over a running predecessor daemon", + { timeout: 180_000 }, + Effect.promise(async () => { + const exe = executorPath(); + let predecessor: PredecessorHandle | null = null; + try { + await ssh(uninstallServiceCommand(exe)); + expect(await waitForGuestHealth(false), "service stopped before staging predecessor").toBe( + true, + ); + + predecessor = await startPredecessor(exe); + const predecessorReady = await waitForGuestHealth(true); + expect( + predecessorReady, + `predecessor daemon became reachable\n${await predecessor.diagnostics()}`, + ).toBe(true); + const predecessorPid = await listenerPid(); + expect(predecessorPid, "predecessor owns the service port").not.toBe(""); + + const install = await ssh(installServiceCommand(exe)); + expect( + install.code, + `service install should take over instead of refusing\nstdout:\n${install.stdout}\nstderr:\n${install.stderr}`, + ).toBe(0); + expect(await waitForGuestHealth(true), "service is reachable after install").toBe(true); + + const ownerPid = await listenerPid(); + const predecessorAlive = (await ssh(processAliveCommand(predecessorPid))).stdout.trim(); + expect(predecessorAlive, "predecessor process was stopped").toBe("dead"); + expect(ownerPid, "the service now owns the port").not.toBe(""); + expect(ownerPid, "the service is a different process").not.toBe(predecessorPid); + } finally { + predecessor?.close(); + await ssh(restoreServiceCommand(exe)); + } + }), +);