/** * Sidecar lifecycle manager run inside the Electron main process. * * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`. * In prod: spawns the bundled CLI binary in foreground daemon mode. * * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN. * The dev sidecar and packaged CLI child announce the structured stdout sentinel * `EXECUTOR_READY:<port>`. Human-readable log text is never part of the desktop * startup contract. */ import { spawn, type ChildProcess } from "node:child_process";import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { homedir } from "node:os";import { resolve, join } from "node:path";import { app } from "electron";import log from "electron-log/main.js";import { normalizeExecutorServerConnection, parseExecutorLocalServerManifest, serializeExecutorLocalServerManifest,} from "@executor-js/sdk/shared";import { loadOrMintLocalAuthToken } from "./local-auth";import { getServerSettings } from "./settings";import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";import { resolveSupervisedDaemonAttach } from "./supervised-daemon";import { classifySidecarExit } from "./sidecar-exit";import { isAppQuitting } from "./app-quit";import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; // Sidecar output is echoed to the terminal (visible when Electron is run// from a shell) AND persisted to main.log under the "sidecar" scope — the// log file is what a user can actually send us after a crash.const sidecarLog = log.scope("sidecar"); // Rolling stderr tail attached to crash reports. Bounded so a chatty// sidecar can't grow it unbounded over a long session.const STDERR_TAIL_LIMIT = 8 * 1024;const READY_SENTINEL = "EXECUTOR_READY"; // Children deliberately stopped via stopSidecar (quit, restart, update) —// their exits are expected and must not be reported as crashes.const expectedExits = new WeakSet<ChildProcess>(); // Main/index.ts subscribes to swap the dead web UI for the in-window crash// screen. A callback (not an import) keeps this module free of window// concerns.export interface SidecarExitNotice { /** False for an expected shutdown, where nothing was sent upstream. */ readonly reported: boolean;}let unexpectedExitListener: ((notice: SidecarExitNotice) => void) | null = null;export const onUnexpectedSidecarExit = (listener: (notice: SidecarExitNotice) => void) => { unexpectedExitListener = listener;}; /** Buffer chunked output into whole lines before handing them to `write`. */const makeLineSplitter = (write: (line: string) => void) => { let buffer = ""; return (text: string) => { buffer += text; const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (line.trim().length > 0) write(line); } };}; export interface SidecarConnection { readonly baseUrl: string; readonly hostname: string; readonly port: number; readonly username: string; readonly authToken: string; /** * 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; readonly ownerVersion: string | null; readonly ownerClient: "cli" | "desktop"; readonly ownerExecutablePath: string | null;} export class SidecarPortInUseError extends Error { readonly port: number; constructor(port: number) { super(`Port ${port} is already in use. Pick another in Settings.`); this.name = "SidecarPortInUseError"; this.port = port; }} interface StartOptions { readonly hostname?: string;} const sidecarManifestPathByPid = new Map<number, string>(); const serverControlDir = (dataDir: string): string => join(dataDir, "server-control");const localServerManifestPath = (dataDir: string): string => join(serverControlDir(dataDir), "server.json"); const isPidAlive = (pid: number): boolean => { if (!Number.isInteger(pid) || pid <= 0) return false; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Node process probing API reports liveness by throwing try { process.kill(pid, 0); return true; } catch { return false; }}; const readManifest = (dataDir: string) => { const path = localServerManifestPath(dataDir); if (!existsSync(path)) return null; return parseExecutorLocalServerManifest(readFileSync(path, "utf8"));}; const removeManifestIfOwnedBy = (dataDir: string, pid: number) => { const manifest = readManifest(dataDir); if (manifest?.pid !== pid) return; rmSync(localServerManifestPath(dataDir), { force: true });}; const writeSidecarManifest = (input: { readonly dataDir: string; readonly scopeDir: string; readonly baseUrl: string; readonly authToken: string; readonly childPid: number;}) => { const connection = normalizeExecutorServerConnection({ kind: "desktop-sidecar", key: "desktop-sidecar", origin: input.baseUrl, displayName: "Desktop sidecar", auth: { kind: "bearer" as const, token: input.authToken }, }); mkdirSync(serverControlDir(input.dataDir), { recursive: true }); const manifestPath = localServerManifestPath(input.dataDir); writeFileSync( manifestPath, serializeExecutorLocalServerManifest({ version: 1, kind: "desktop-sidecar", pid: input.childPid, startedAt: new Date().toISOString(), dataDir: input.dataDir, scopeDir: input.scopeDir, connection, owner: { client: "desktop", version: app.getVersion() || null, executablePath: process.execPath || null, }, }), { mode: 0o600 }, ); // The manifest embeds the bearer token; keep it owner-only even if a looser // file already existed (writeFileSync's mode does not re-apply on overwrite). chmodSync(manifestPath, 0o600); sidecarManifestPathByPid.set(input.childPid, input.dataDir);}; 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.exe" : "executor"; const binaryPath = join(process.resourcesPath, "executor", binaryName); return { command: binaryPath, args: [ "daemon", "run", "--foreground", "--port", String(input.port), "--hostname", input.hostname, // Combined `--flag=value` form: the auth token is base64url and can // start with "-", which the space-separated form makes the CLI parser // read as an unknown flag, so the daemon prints help and exits and the // desktop reports a fatal "server crashed during startup". Persistent // until the token rotates, and cross-platform (~1 in 64 fresh installs). `--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, cliManagedManifest: false };}; const resolveClientDir = (): string => { const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); return resolve(repoRoot, "apps/local/dist");}; const delay = (ms: number): Promise<void> => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { const hostname = options.hostname ?? "127.0.0.1"; const settings = getServerSettings(); // 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 // show up in the other, and user-facing commands like // `executor mcp --scope ~/.executor` stay copy-paste-friendly. Electron's // userData (set in main/index.ts) is still used for electron-store, // electron-log, and window-state — those stay app-scoped to avoid colliding // with anything else under HOME. const scopeDir = join(homedir(), ".executor"); const dataDir = scopeDir; mkdirSync(dataDir, { recursive: true }); // The stable bearer token from auth.json (shared with the CLI). The main // process holds it so it can inject the header into the webview; the child // validates against the same value. Always present — auth is unconditional. const authToken = loadOrMintLocalAuthToken(dataDir); const { command, args, cwd, cliManagedManifest } = resolveSidecarCommand({ port: settings.port, hostname, authToken, }); const clientDir = cliManagedManifest ? null : resolveClientDir(); if (!cliManagedManifest && clientDir && !existsSync(clientDir)) { // 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.`, ); } // No process-level startup lock: the dev sidecar child opens the DB through // openOwnedLocalDatabase, whose ownership lock is the real gate. If the child // loses the race, startup fails as before; only the packaged supervised boot // path attaches to an existing daemon. const webBaseUrl = `http://${hostname}:${settings.port}`; const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, EXECUTOR_PORT: String(settings.port), EXECUTOR_HOST: hostname, EXECUTOR_WEB_BASE_URL: webBaseUrl, PORT: String(settings.port), // The bearer token the child validates and the main process injects into // the webview. Always set — auth is unconditional. EXECUTOR_AUTH_TOKEN: authToken, ...(clientDir ? { EXECUTOR_CLIENT_DIR: clientDir } : {}), EXECUTOR_SCOPE_DIR: scopeDir, EXECUTOR_DATA_DIR: dataDir, EXECUTOR_CLIENT: "desktop", // Crash reporting (desktop builds with a baked-in DSN only). The // CLI's `executor web` never sets these, so the shared server code // stays telemetry-free outside the desktop app. ...sidecarCrashReportingEnv(), }, }); return new Promise<SidecarConnection>((resolveStart, rejectStart) => { let stderrBuffer = ""; let stdoutControlBuffer = ""; let resolved = false; let rejected = false; const logStdoutLine = makeLineSplitter((line) => sidecarLog.info(line)); const logStderrLine = makeLineSplitter((line) => sidecarLog.error(line)); const reject = (err: Error) => { if (resolved || rejected) return; rejected = true; // oxlint-disable-next-line executor/no-promise-reject -- boundary: sidecar startup surfaces as a rejected promise rejectStart(err); }; const onStdout = (chunk: Buffer) => { const text = chunk.toString("utf8"); process.stdout.write(`[executor-server] ${text}`); logStdoutLine(text); stdoutControlBuffer += text; const rawControlLines = stdoutControlBuffer.split(/\r?\n/); stdoutControlBuffer = rawControlLines.pop() ?? ""; const stdoutLines = rawControlLines .map((line) => line.trim()) .filter((line) => line.length > 0); const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`)); if (readyLine && !resolved) { if (!child.pid) { reject( // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar startup failure surfaces here as a rejected start promise new Error("Sidecar became ready before Electron reported a child pid."), ); return; } resolved = true; const port = parseInt(readyLine.slice(`${READY_SENTINEL}:`.length), 10); const baseUrl = `http://${hostname}:${port}`; if (!cliManagedManifest) { writeSidecarManifest({ dataDir, scopeDir, baseUrl, authToken, childPid: child.pid, }); } resolveStart({ baseUrl, hostname, port, username: SERVER_SETTINGS_USERNAME, authToken, child, supervisedDaemon: false, ownerVersion: app.getVersion() || null, ownerClient: "desktop", ownerExecutablePath: command, }); } }; const onStderr = (chunk: Buffer) => { const text = chunk.toString("utf8"); stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT); process.stderr.write(`[executor-server] ${text}`); logStderrLine(text); }; const onExit = (code: number | null, signal: NodeJS.Signals | null) => { if (resolved) { // Post-boot exit. Expected when we stopped it ourselves (quit, restart, // update) and when something outside the app interrupted it — the child // shares Electron's process group, so a group-wide SIGINT reaches it // directly and surfaces as code 130. Only the rest is a crash under a // live window, reported upstream with the stderr tail. const classification = classifySidecarExit({ code, signal, stoppedByUs: expectedExits.has(child), appQuitting: isAppQuitting(), }); if (classification.kind === "managed-stop") { sidecarLog.info(`exited (code=${code} signal=${signal}) — ${classification.reason}`); return; } if (classification.kind === "external-shutdown") { // The server really is gone, so the window still has to say so; it // just wasn't a crash, so nothing is reported. sidecarLog.info( `Sidecar shut down by ${classification.reason} (code=${code} signal=${signal})`, ); unexpectedExitListener?.({ reported: false }); return; } const message = `Sidecar exited unexpectedly (code=${code} signal=${signal})`; sidecarLog.error(message); reportSidecarCrash(message, stderrBuffer); unexpectedExitListener?.({ reported: true }); return; } if (rejected) return; // Detect bind failure — the Node listener prints either "EADDRINUSE" or // "address already in use" on stderr before exiting non-zero. if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) { reject(new SidecarPortInUseError(settings.port)); return; } const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`; // oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise reject(new Error(message)); }; child.stdout?.on("data", onStdout); child.stderr?.on("data", onStderr); child.once("exit", onExit); });} /** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */const isDaemonReachable = async (origin: string): Promise<boolean> => { 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;}; /** * Attach to an already-running OS-supervised daemon instead of spawning our own * sidecar. Reads `server.json`, confirms the endpoint answers, and returns a * child-less `SidecarConnection` flagged `supervisedDaemon: true`. Returns null * when no usable supervised daemon is present. * * 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<SidecarConnection | null> { const dataDir = join(homedir(), ".executor"); const manifest = readManifest(dataDir); const decision = await resolveSupervisedDaemonAttach(manifest, { isReachable: isDaemonReachable, isPidAlive, }); if (decision.kind === "attach") { const { manifest, authToken } = decision; const origin = manifest.connection.origin; 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, ownerVersion: manifest.owner.version, ownerClient: manifest.owner.client, ownerExecutablePath: manifest.owner.executablePath, }; } if (decision.kind === "remove-stale-manifest") { removeManifestIfOwnedBy(dataDir, decision.pid); return null; } if (!manifest || manifest.kind !== "cli-daemon") return null; sidecarLog.warn( `supervised daemon at ${manifest.connection.origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`, ); return null;} export async function stopSidecar(child: ChildProcess): Promise<void> { expectedExits.add(child); const cleanupManifest = () => { if (!child.pid) return; const dataDir = sidecarManifestPathByPid.get(child.pid); if (!dataDir) return; removeManifestIfOwnedBy(dataDir, child.pid); sidecarManifestPathByPid.delete(child.pid); }; if (child.exitCode !== null || child.killed) { cleanupManifest(); return; } return new Promise<void>((resolveStop) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); cleanupManifest(); resolveStop(); }, 5000); child.once("exit", () => { clearTimeout(timeout); cleanupManifest(); resolveStop(); }); child.kill("SIGTERM"); });} export type { DesktopServerSettings };
/**
* Sidecar lifecycle manager run inside the Electron main process.
*
* In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`.
* In prod: spawns the bundled CLI binary in foreground daemon mode.
*
* Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN.
* The dev sidecar and packaged CLI child announce the structured stdout sentinel
* `EXECUTOR_READY:<port>`. Human-readable log text is never part of the desktop
* startup contract.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve, join } from "node:path";
import { app } from "electron";
import log from "electron-log/main.js";
import {
normalizeExecutorServerConnection,
parseExecutorLocalServerManifest,
serializeExecutorLocalServerManifest,
} from "@executor-js/sdk/shared";
import { loadOrMintLocalAuthToken } from "./local-auth";
import { getServerSettings } from "./settings";
import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
import { resolveSupervisedDaemonAttach } from "./supervised-daemon";
import { classifySidecarExit } from "./sidecar-exit";
import { isAppQuitting } from "./app-quit";
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
// Sidecar output is echoed to the terminal (visible when Electron is run
// from a shell) AND persisted to main.log under the "sidecar" scope — the
// log file is what a user can actually send us after a crash.
const sidecarLog = log.scope("sidecar");
// Rolling stderr tail attached to crash reports. Bounded so a chatty
// sidecar can't grow it unbounded over a long session.
const STDERR_TAIL_LIMIT = 8 * 1024;
const READY_SENTINEL = "EXECUTOR_READY";
// Children deliberately stopped via stopSidecar (quit, restart, update) —
// their exits are expected and must not be reported as crashes.
const expectedExits = new WeakSet<ChildProcess>();
// Main/index.ts subscribes to swap the dead web UI for the in-window crash
// screen. A callback (not an import) keeps this module free of window
// concerns.
export interface SidecarExitNotice {
/** False for an expected shutdown, where nothing was sent upstream. */
readonly reported: boolean;
}
let unexpectedExitListener: ((notice: SidecarExitNotice) => void) | null = null;
export const onUnexpectedSidecarExit = (listener: (notice: SidecarExitNotice) => void) => {
unexpectedExitListener = listener;
};
/** Buffer chunked output into whole lines before handing them to `write`. */
const makeLineSplitter = (write: (line: string) => void) => {
let buffer = "";
return (text: string) => {
buffer += text;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim().length > 0) write(line);
}
};
};
export interface SidecarConnection {
readonly baseUrl: string;
readonly hostname: string;
readonly port: number;
readonly username: string;
readonly authToken: string;
/**
* 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;
readonly ownerVersion: string | null;
readonly ownerClient: "cli" | "desktop";
readonly ownerExecutablePath: string | null;
}
export class SidecarPortInUseError extends Error {
readonly port: number;
constructor(port: number) {
super(`Port ${port} is already in use. Pick another in Settings.`);
this.name = "SidecarPortInUseError";
this.port = port;
}
}
interface StartOptions {
readonly hostname?: string;
}
const sidecarManifestPathByPid = new Map<number, string>();
const serverControlDir = (dataDir: string): string => join(dataDir, "server-control");
const localServerManifestPath = (dataDir: string): string =>
join(serverControlDir(dataDir), "server.json");
const isPidAlive = (pid: number): boolean => {
if (!Number.isInteger(pid) || pid <= 0) return false;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Node process probing API reports liveness by throwing
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const readManifest = (dataDir: string) => {
const path = localServerManifestPath(dataDir);
if (!existsSync(path)) return null;
return parseExecutorLocalServerManifest(readFileSync(path, "utf8"));
};
const removeManifestIfOwnedBy = (dataDir: string, pid: number) => {
const manifest = readManifest(dataDir);
if (manifest?.pid !== pid) return;
rmSync(localServerManifestPath(dataDir), { force: true });
};
const writeSidecarManifest = (input: {
readonly dataDir: string;
readonly scopeDir: string;
readonly baseUrl: string;
readonly authToken: string;
readonly childPid: number;
}) => {
const connection = normalizeExecutorServerConnection({
kind: "desktop-sidecar",
key: "desktop-sidecar",
origin: input.baseUrl,
displayName: "Desktop sidecar",
auth: { kind: "bearer" as const, token: input.authToken },
});
mkdirSync(serverControlDir(input.dataDir), { recursive: true });
const manifestPath = localServerManifestPath(input.dataDir);
writeFileSync(
manifestPath,
serializeExecutorLocalServerManifest({
version: 1,
kind: "desktop-sidecar",
pid: input.childPid,
startedAt: new Date().toISOString(),
dataDir: input.dataDir,
scopeDir: input.scopeDir,
connection,
owner: {
client: "desktop",
version: app.getVersion() || null,
executablePath: process.execPath || null,
},
}),
{ mode: 0o600 },
);
// The manifest embeds the bearer token; keep it owner-only even if a looser
// file already existed (writeFileSync's mode does not re-apply on overwrite).
chmodSync(manifestPath, 0o600);
sidecarManifestPathByPid.set(input.childPid, input.dataDir);
};
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.exe" : "executor";
const binaryPath = join(process.resourcesPath, "executor", binaryName);
return {
command: binaryPath,
args: [
"daemon",
"run",
"--foreground",
"--port",
String(input.port),
"--hostname",
input.hostname,
// Combined `--flag=value` form: the auth token is base64url and can
// start with "-", which the space-separated form makes the CLI parser
// read as an unknown flag, so the daemon prints help and exits and the
// desktop reports a fatal "server crashed during startup". Persistent
// until the token rotates, and cross-platform (~1 in 64 fresh installs).
`--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, cliManagedManifest: false };
};
const resolveClientDir = (): string => {
const repoRoot = resolve(import.meta.dirname, "..", "..", "..", "..");
return resolve(repoRoot, "apps/local/dist");
};
const delay = (ms: number): Promise<void> =>
new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> {
const hostname = options.hostname ?? "127.0.0.1";
const settings = getServerSettings();
// 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
// show up in the other, and user-facing commands like
// `executor mcp --scope ~/.executor` stay copy-paste-friendly. Electron's
// userData (set in main/index.ts) is still used for electron-store,
// electron-log, and window-state — those stay app-scoped to avoid colliding
// with anything else under HOME.
const scopeDir = join(homedir(), ".executor");
const dataDir = scopeDir;
mkdirSync(dataDir, { recursive: true });
// The stable bearer token from auth.json (shared with the CLI). The main
// process holds it so it can inject the header into the webview; the child
// validates against the same value. Always present — auth is unconditional.
const authToken = loadOrMintLocalAuthToken(dataDir);
const { command, args, cwd, cliManagedManifest } = resolveSidecarCommand({
port: settings.port,
hostname,
authToken,
});
const clientDir = cliManagedManifest ? null : resolveClientDir();
if (!cliManagedManifest && clientDir && !existsSync(clientDir)) {
// 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.`,
);
}
// No process-level startup lock: the dev sidecar child opens the DB through
// openOwnedLocalDatabase, whose ownership lock is the real gate. If the child
// loses the race, startup fails as before; only the packaged supervised boot
// path attaches to an existing daemon.
const webBaseUrl = `http://${hostname}:${settings.port}`;
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
EXECUTOR_PORT: String(settings.port),
EXECUTOR_HOST: hostname,
EXECUTOR_WEB_BASE_URL: webBaseUrl,
PORT: String(settings.port),
// The bearer token the child validates and the main process injects into
// the webview. Always set — auth is unconditional.
EXECUTOR_AUTH_TOKEN: authToken,
...(clientDir ? { EXECUTOR_CLIENT_DIR: clientDir } : {}),
EXECUTOR_SCOPE_DIR: scopeDir,
EXECUTOR_DATA_DIR: dataDir,
EXECUTOR_CLIENT: "desktop",
// Crash reporting (desktop builds with a baked-in DSN only). The
// CLI's `executor web` never sets these, so the shared server code
// stays telemetry-free outside the desktop app.
...sidecarCrashReportingEnv(),
},
});
return new Promise<SidecarConnection>((resolveStart, rejectStart) => {
let stderrBuffer = "";
let stdoutControlBuffer = "";
let resolved = false;
let rejected = false;
const logStdoutLine = makeLineSplitter((line) => sidecarLog.info(line));
const logStderrLine = makeLineSplitter((line) => sidecarLog.error(line));
const reject = (err: Error) => {
if (resolved || rejected) return;
rejected = true;
// oxlint-disable-next-line executor/no-promise-reject -- boundary: sidecar startup surfaces as a rejected promise
rejectStart(err);
};
const onStdout = (chunk: Buffer) => {
const text = chunk.toString("utf8");
process.stdout.write(`[executor-server] ${text}`);
logStdoutLine(text);
stdoutControlBuffer += text;
const rawControlLines = stdoutControlBuffer.split(/\r?\n/);
stdoutControlBuffer = rawControlLines.pop() ?? "";
const stdoutLines = rawControlLines
.map((line) => line.trim())
.filter((line) => line.length > 0);
const readyLine = stdoutLines.find((line) => line.startsWith(`${READY_SENTINEL}:`));
if (readyLine && !resolved) {
if (!child.pid) {
reject(
// oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar startup failure surfaces here as a rejected start promise
new Error("Sidecar became ready before Electron reported a child pid."),
);
return;
}
resolved = true;
const port = parseInt(readyLine.slice(`${READY_SENTINEL}:`.length), 10);
const baseUrl = `http://${hostname}:${port}`;
if (!cliManagedManifest) {
writeSidecarManifest({
dataDir,
scopeDir,
baseUrl,
authToken,
childPid: child.pid,
});
}
resolveStart({
baseUrl,
hostname,
port,
username: SERVER_SETTINGS_USERNAME,
authToken,
child,
supervisedDaemon: false,
ownerVersion: app.getVersion() || null,
ownerClient: "desktop",
ownerExecutablePath: command,
});
}
};
const onStderr = (chunk: Buffer) => {
const text = chunk.toString("utf8");
stderrBuffer = (stderrBuffer + text).slice(-STDERR_TAIL_LIMIT);
process.stderr.write(`[executor-server] ${text}`);
logStderrLine(text);
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (resolved) {
// Post-boot exit. Expected when we stopped it ourselves (quit, restart,
// update) and when something outside the app interrupted it — the child
// shares Electron's process group, so a group-wide SIGINT reaches it
// directly and surfaces as code 130. Only the rest is a crash under a
// live window, reported upstream with the stderr tail.
const classification = classifySidecarExit({
code,
signal,
stoppedByUs: expectedExits.has(child),
appQuitting: isAppQuitting(),
});
if (classification.kind === "managed-stop") {
sidecarLog.info(`exited (code=${code} signal=${signal}) — ${classification.reason}`);
return;
}
if (classification.kind === "external-shutdown") {
// The server really is gone, so the window still has to say so; it
// just wasn't a crash, so nothing is reported.
sidecarLog.info(
`Sidecar shut down by ${classification.reason} (code=${code} signal=${signal})`,
);
unexpectedExitListener?.({ reported: false });
return;
}
const message = `Sidecar exited unexpectedly (code=${code} signal=${signal})`;
sidecarLog.error(message);
reportSidecarCrash(message, stderrBuffer);
unexpectedExitListener?.({ reported: true });
return;
}
if (rejected) return;
// Detect bind failure — the Node listener prints either "EADDRINUSE" or
// "address already in use" on stderr before exiting non-zero.
if (/EADDRINUSE|address already in use/i.test(stderrBuffer)) {
reject(new SidecarPortInUseError(settings.port));
return;
}
const message = `Sidecar exited before ready (code=${code} signal=${signal}). Stderr:\n${stderrBuffer}`;
// oxlint-disable-next-line executor/no-error-constructor -- boundary: sidecar boot failure surfaces here as a rejected start promise
reject(new Error(message));
};
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
child.once("exit", onExit);
});
}
/** Probe the unauthenticated Executor health endpoint without disclosing the saved bearer. */
const isDaemonReachable = async (origin: string): Promise<boolean> => {
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;
};
/**
* Attach to an already-running OS-supervised daemon instead of spawning our own
* sidecar. Reads `server.json`, confirms the endpoint answers, and returns a
* child-less `SidecarConnection` flagged `supervisedDaemon: true`. Returns null
* when no usable supervised daemon is present.
*
* 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<SidecarConnection | null> {
const dataDir = join(homedir(), ".executor");
const manifest = readManifest(dataDir);
const decision = await resolveSupervisedDaemonAttach(manifest, {
isReachable: isDaemonReachable,
isPidAlive,
});
if (decision.kind === "attach") {
const { manifest, authToken } = decision;
const origin = manifest.connection.origin;
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,
ownerVersion: manifest.owner.version,
ownerClient: manifest.owner.client,
ownerExecutablePath: manifest.owner.executablePath,
};
}
if (decision.kind === "remove-stale-manifest") {
removeManifestIfOwnedBy(dataDir, decision.pid);
return null;
}
if (!manifest || manifest.kind !== "cli-daemon") return null;
sidecarLog.warn(
`supervised daemon at ${manifest.connection.origin} (pid ${manifest.pid}) did not answer the health probe; keeping its manifest because the process is still alive`,
);
return null;
}
export async function stopSidecar(child: ChildProcess): Promise<void> {
expectedExits.add(child);
const cleanupManifest = () => {
if (!child.pid) return;
const dataDir = sidecarManifestPathByPid.get(child.pid);
if (!dataDir) return;
removeManifestIfOwnedBy(dataDir, child.pid);
sidecarManifestPathByPid.delete(child.pid);
};
if (child.exitCode !== null || child.killed) {
cleanupManifest();
return;
}
return new Promise<void>((resolveStop) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
cleanupManifest();
resolveStop();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
cleanupManifest();
resolveStop();
});
child.kill("SIGTERM");
});
}
export type { DesktopServerSettings };