From 50c18a97344cf8ba65468f7b9e6a8e243a0440fe Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sat, 13 Jun 2026 11:41:46 -0700 Subject: [PATCH] Desktop: attach to the supervised daemon instead of a private sidecar The Electron app becomes a thin client of the OS-supervised gateway: - On packaged macOS launch it attaches to a running supervised daemon, or (with a one-time consent prompt) registers one by pointing a launchd LaunchAgent at the bundled sidecar binary in supervised mode. Falls back to the existing managed sidecar everywhere else. - Quitting the app, restarting the window, updating, or resetting state no longer stops a supervised daemon -- it keeps serving MCP. The old 'another server owns the data dir -> fatal dialog' path becomes a clean attach. - The sidecar entry, when supervised, self-writes the discovery manifest and reads its password from service.key. - A crash monitor shows a reconnecting overlay while launchd restarts the daemon; regenerating the password reinstalls + re-points the window. --- apps/desktop/src/main/index.ts | 212 +++++++++++++++++++++++++- apps/desktop/src/main/service.ts | 233 +++++++++++++++++++++++++++++ apps/desktop/src/main/sidecar.ts | 72 ++++++++- apps/desktop/src/sidecar/server.ts | 69 ++++++++- 4 files changed, 573 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/main/service.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e4d3d181f..249eba6d2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -18,6 +19,7 @@ import updater from "electron-updater"; const { autoUpdater } = updater; type UpdateInfo = { readonly version: string }; import { + attachToSupervisedDaemon, startSidecar, stopSidecar, onUnexpectedSidecarExit, @@ -33,6 +35,12 @@ import { reportAProblem, } from "./diagnostics"; import { sidecarCrashHtml } from "./crash-screen"; +import { + installSupervisedService, + restartSupervisedService, + supervisedServiceStatus, + uninstallSupervisedService, +} from "./service"; import { announceBackup, confirmResetState, resetExecutorState } from "./reset-state"; import { getServerProfiles, @@ -99,6 +107,124 @@ const ensureSingleInstance = () => { return true; }; +/** + * Stop the local server only when WE own it. A supervised daemon (launchd/etc.) + * outlives this app by design — quitting, restarting the window, or resetting + * state must never kill it. Spawned sidecars (`child` set) are stopped as before. + */ +const stopConnection = async (conn: SidecarConnection): Promise => { + if (conn.supervisedDaemon || !conn.child) return; + await stopSidecar(conn.child); +}; + +// 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)); + +/** Poll for a reachable supervised daemon until the deadline. */ +const waitForSupervisedAttach = async (timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + for (;;) { + const attached = await attachToSupervisedDaemon(); + if (attached) return attached; + if (Date.now() >= deadline) return null; + await delay(300); + } +}; + +const confirmEnableBackgroundService = async (): Promise => { + const { response } = await dialog.showMessageBox({ + type: "question", + 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.", + buttons: ["Keep running in the background", "Not now"], + defaultId: 0, + cancelId: 1, + }); + return response === 0; +}; + +/** + * Resolve a connection to the OS-supervised daemon, installing it on first run + * (with consent). Returns null when supervision is unavailable or the user + * declined — the caller then falls back to managed-spawn. + */ +const ensureSupervisedConnection = async (): Promise => { + // 1. Already running → attach. + const attached = await attachToSupervisedDaemon(); + if (attached) return attached; + + const status = await supervisedServiceStatus(); + if (!status.supported) return null; + + // 2. Registered but not currently serving → kick it and wait. + if (status.registered) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn + try { + await restartSupervisedService(); + } catch (error) { + log.warn("Failed to kickstart supervised service", error); + } + return waitForSupervisedAttach(15_000); + } + + // 3. First run → ask, then install + start. The unit carries no secret; the + // supervised daemon mints/loads its bearer from auth.json under DESKTOP_DATA_DIR. + if (!(await confirmEnableBackgroundService())) return null; + const settings = getServerSettings(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches + try { + await installSupervisedService({ + port: settings.port, + dataDir: DESKTOP_DATA_DIR, + }); + } catch (error) { + log.error("Failed to install supervised service; using managed sidecar", error); + return null; + } + 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. +let supervisedMonitorTimer: ReturnType | null = null; +let supervisedDaemonDown = false; + +const stopSupervisedMonitor = () => { + if (supervisedMonitorTimer) clearInterval(supervisedMonitorTimer); + supervisedMonitorTimer = null; + supervisedDaemonDown = false; +}; + +const armSupervisedMonitor = () => { + stopSupervisedMonitor(); + supervisedMonitorTimer = setInterval(() => { + void (async () => { + const live = await attachToSupervisedDaemon(); + const window = liveMainWindow(); + if (!live) { + if (!supervisedDaemonDown && window) { + supervisedDaemonDown = true; + const html = sidecarCrashHtml({ reported: errorReportingEnabled }); + void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + } + return; + } + if (supervisedDaemonDown) { + supervisedDaemonDown = false; + connection = live; + installBearerAuthHeader(live.baseUrl, live.authToken); + if (window) void window.loadURL(live.baseUrl); + } + })(); + }, 10_000); +}; + const installBearerAuthHeader = (origin: string, token: string | null) => { authHeaderUnsubscribe?.(); authHeaderUnsubscribe = null; @@ -289,8 +415,15 @@ 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. + if (connection?.supervisedDaemon) { + const window = liveMainWindow(); + if (window) await window.loadURL(connection.baseUrl); + return toDesktopServerConnection(connection); + } if (connection) { - await stopSidecar(connection.child); + await stopConnection(connection); connection = null; } const next = await startWithCurrentSettings(); @@ -330,12 +463,53 @@ const registerIpcHandlers = () => { (_evt, patch: Partial): DesktopServerSettings => updateServerSettings(patch), ); - // Rotate the bearer token (auth.json) and restart the sidecar so it loads the - // new token; the webview header is re-injected by restartSidecarAndReload. - ipcMain.handle("executor:server:rotate-token", (): Promise => { + // Rotate the bearer token (auth.json). A supervised daemon must be restarted + // so it re-reads auth.json at boot, then re-attached; a managed sidecar is + // restarted in-process. Either way the webview header is re-injected. + ipcMain.handle("executor:server:rotate-token", async (): Promise => { rotateServerToken(); + if (connection?.supervisedDaemon) { + const previous = connection; + await restartSupervisedService(); + const active = (await waitForSupervisedAttach(15_000)) ?? previous; + connection = active; + installBearerAuthHeader(active.baseUrl, active.authToken); + const window = liveMainWindow(); + if (window) await window.loadURL(active.baseUrl); + 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. + ipcMain.handle("executor:service:status", () => supervisedServiceStatus()); + ipcMain.handle( + "executor:service:set-enabled", + async (_evt, enabled: unknown): Promise => { + if (typeof enabled !== "boolean") return false; + if (enabled) { + const settings = getServerSettings(); + await installSupervisedService({ + port: settings.port, + dataDir: DESKTOP_DATA_DIR, + }); + const next = await waitForSupervisedAttach(15_000); + if (next) { + if (connection && !connection.supervisedDaemon) await stopConnection(connection); + connection = next; + armSupervisedMonitor(); + installBearerAuthHeader(next.baseUrl, next.authToken); + const window = liveMainWindow(); + if (window) await window.loadURL(next.baseUrl); + } + return true; + } + stopSupervisedMonitor(); + await uninstallSupervisedService(DESKTOP_DATA_DIR); + return true; + }, + ); ipcMain.handle("executor:server-profiles:get", (): string | null => getServerProfiles()); ipcMain.handle("executor:server-profiles:set", (_evt, value: unknown): void => { if (typeof value !== "string") return; @@ -354,7 +528,7 @@ const registerIpcHandlers = () => { ipcMain.handle("executor:state:reset", async (): Promise => { if (!(await confirmResetState())) return false; if (connection) { - await stopSidecar(connection.child); + await stopConnection(connection); connection = null; } const { backupDir } = resetExecutorState(); @@ -409,9 +583,10 @@ const promptInstallUpdate = async (version: string) => { cancelId: 1, }); if (response.response === 0) { - // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. + // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A + // supervised daemon is left running — it's independent of this bundle. if (connection) { - await stopSidecar(connection.child); + await stopConnection(connection); connection = null; } autoUpdater.quitAndInstall(false, true); @@ -584,6 +759,21 @@ const boot = async () => { // self-heal as the fatal startup path). void runUpdateCheck({ alertOnFail: false }); }); + // 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; + // dev and unsupported platforms keep managed-spawn. + if (app.isPackaged) { + const supervised = await ensureSupervisedConnection(); + if (supervised) { + connection = supervised; + await createWindow(supervised); // installs the Basic-auth header itself + armSupervisedMonitor(); + void runUpdateCheck({ alertOnFail: false }); + return; + } + } connection = await startWithCurrentSettings(); if (!connection && lastSidecarStartError != null) { // Port conflicts already showed their dialog inside @@ -624,8 +814,14 @@ if (ensureSingleInstance()) { app.on("before-quit", async (event) => { if (!connection) return; + // A supervised daemon must keep serving after the app quits — don't stop it, + // and don't block the quit on teardown we don't need to do. + if (connection.supervisedDaemon) { + connection = null; + return; + } event.preventDefault(); - await stopSidecar(connection.child); + await stopConnection(connection); connection = null; app.exit(0); }); diff --git a/apps/desktop/src/main/service.ts b/apps/desktop/src/main/service.ts new file mode 100644 index 000000000..c6e5c9dde --- /dev/null +++ b/apps/desktop/src/main/service.ts @@ -0,0 +1,233 @@ +/** + * 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. + */ + +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, userInfo } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { app } from "electron"; +import log from "electron-log/main.js"; + +const serviceLog = log.scope("service"); +const execFileAsync = promisify(execFile); + +export const SERVICE_LABEL = "sh.executor.daemon"; + +interface CommandResult { + readonly code: number; + readonly stdout: string; + 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"); + +interface PlistOptions { + readonly label: string; + readonly programArguments: ReadonlyArray; + readonly environment: Record; + readonly stdoutPath: string; + readonly stderrPath: string; + readonly workingDirectory: 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)} + + +`; +}; + +/** + * 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", + timeout: 5000, + }); + const path = stdout.trim(); + return path.length > 0 ? path : process.env.PATH; + } catch { + return process.env.PATH; + } +}; + +export interface SupervisedServiceStatus { + readonly supported: boolean; + readonly registered: boolean; + readonly running: boolean; +} + +const isSupported = (): boolean => app.isPackaged && process.platform === "darwin"; + +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 }; +}; + +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, + }); + writeFileSync(plistPath(), plist, { mode: 0o600 }); + + // Re-bootstrap cleanly so a stale registration doesn't make bootstrap fail. + await runCommand("launchctl", ["bootout", 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()}`, + ); + } + await runCommand("launchctl", ["enable", serviceTarget(uid)]); + serviceLog.info(`installed supervised service 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"); +}; + +/** Restart the supervised daemon atomically (kill + relaunch via launchd). */ +export const restartSupervisedService = async (): Promise => { + await runCommand("launchctl", ["kickstart", "-k", serviceTarget(currentUid())]); +}; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 190f9349c..7ffbf8e48 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -68,7 +68,17 @@ export interface SidecarConnection { readonly port: number; readonly username: string; readonly authToken: string; - readonly child: ChildProcess; + /** + * The child process we spawned and own, or `null` when we attached to an + * OS-supervised daemon that outlives this app (see `supervisedDaemon`). + */ + readonly child: ChildProcess | null; + /** + * True when this connection points at an OS-supervised daemon (launchd/etc.) + * that we did NOT spawn and must NOT stop on quit — quitting the app should + * leave MCP serving. + */ + readonly supervisedDaemon: boolean; } export class SidecarPortInUseError extends Error { @@ -353,6 +363,7 @@ 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 headers: Record = {}; + if (authToken) headers.Authorization = `Bearer ${authToken}`; + await fetch(origin, { signal: controller.signal, headers, redirect: "manual" }); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +}; + +/** + * Attach to an already-running OS-supervised daemon instead of spawning our own + * sidecar. Reads `server.json`, confirms the recorded process is alive and the + * endpoint answers, and returns a child-less `SidecarConnection` flagged + * `supervisedDaemon: true`. Returns null when no usable supervised daemon is + * present (the caller then falls back to managed-spawn). + * + * Only a `cli-daemon` manifest is treated as supervised — a `desktop-sidecar` + * manifest belongs to a managed sidecar (ours or another desktop instance) and + * is handled by the existing single-instance / ownership logic. + */ +export async function attachToSupervisedDaemon(): Promise { + const dataDir = join(homedir(), ".executor"); + const manifest = readManifest(dataDir); + if (!manifest || manifest.kind !== "cli-daemon") return null; + if (!isPidAlive(manifest.pid)) { + removeManifestIfOwnedBy(dataDir, manifest.pid); + return null; + } + + const origin = manifest.connection.origin; + const auth = manifest.connection.auth; + const authToken = auth && auth.kind === "bearer" ? auth.token : ""; + if (!(await isDaemonReachable(origin, authToken))) return null; + + const url = new URL(origin); + sidecarLog.info(`attaching to supervised daemon at ${origin} (pid ${manifest.pid})`); + return { + baseUrl: origin, + hostname: url.hostname, + port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), + username: SERVER_SETTINGS_USERNAME, + authToken, + child: null, + supervisedDaemon: true, + }; +} + export async function stopSidecar(child: ChildProcess): Promise { expectedExits.add(child); const cleanupManifest = () => { diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts index 8b6ec4e4a..3ff25e6b8 100644 --- a/apps/desktop/src/sidecar/server.ts +++ b/apps/desktop/src/sidecar/server.ts @@ -63,16 +63,73 @@ if (sentryDsn) { }); } +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { + normalizeExecutorServerConnection, + parseExecutorLocalServerManifest, + serializeExecutorLocalServerManifest, +} from "@executor-js/sdk/shared"; import { startServer } from "@executor-js/local"; const requestedPort = parseInt(process.env.EXECUTOR_PORT ?? "0", 10); const hostname = process.env.EXECUTOR_HOST ?? "127.0.0.1"; -// The main process mints/loads the bearer token and threads it in so it can -// inject the same token into the webview. Falls back to the auth.json token -// when absent (e.g. the sidecar booted standalone). +// The main process mints/loads the bearer token and threads it in via env so it +// can inject the same token into the webview. When absent (e.g. supervised boot +// under launchd, or a standalone sidecar), startServer mints/loads auth.json. const authToken = process.env.EXECUTOR_AUTH_TOKEN; const clientDir = process.env.EXECUTOR_CLIENT_DIR; +// Supervised mode: launchd/systemd runs this binary directly (no Electron +// parent). Two things the parent normally does, this process must do itself: +// (1) get the bearer token (EXECUTOR_AUTH_TOKEN, else startServer mints/loads +// auth.json — the unit never carries the secret), and (2) write server.json so +// clients can discover us. +const supervised = process.env.EXECUTOR_SUPERVISED === "1"; +const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); +const serverControlDir = join(dataDir, "server-control"); +const manifestPath = join(serverControlDir, "server.json"); + +const writeSupervisedManifest = (port: number, token: string) => { + const connection = normalizeExecutorServerConnection({ + origin: `http://${hostname}:${port}`, + displayName: "Supervised daemon", + auth: { kind: "bearer" as const, token }, + }); + mkdirSync(serverControlDir, { recursive: true }); + writeFileSync( + manifestPath, + serializeExecutorLocalServerManifest({ + version: 1, + // "cli-daemon" marks an OS-supervised gateway that thin views (the + // desktop app, CLI) attach to rather than spawn — see the desktop's + // attachToSupervisedDaemon. + kind: "cli-daemon", + pid: process.pid, + startedAt: new Date().toISOString(), + dataDir, + scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? dataDir, + connection, + owner: { + client: "desktop", + version: process.env.EXECUTOR_SERVICE_VERSION ?? null, + executablePath: process.execPath || null, + }, + }), + ); +}; + +const removeOwnManifest = () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cleanup on shutdown + try { + if (!existsSync(manifestPath)) return; + const parsed = parseExecutorLocalServerManifest(readFileSync(manifestPath, "utf8")); + if (parsed?.pid === process.pid) rmSync(manifestPath, { force: true }); + } catch { + // ignore + } +}; + const server = await startServer({ port: requestedPort, hostname, @@ -80,13 +137,17 @@ const server = await startServer({ clientDir, }); -// Sentinel parsed by the main process to learn the bound port. +if (supervised) writeSupervisedManifest(server.port, server.authToken); + +// Sentinel parsed by the main process to learn the bound port (harmless under +// launchd, where stdout goes to the daemon log). console.log(`EXECUTOR_READY:${server.port}`); const stop = async (code: number) => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: shutdown path must terminate even when stop() throws try { await server.stop(); + if (supervised) removeOwnManifest(); } finally { process.exit(code); }