import { execFile } from "node:child_process"; import { readFileSync } from "node:fs"; import { homedir, userInfo } from "node:os"; import { FileSystem, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; import * as Effect from "effect/Effect"; import { resolveExecutorDataDir } from "./local-server-manifest"; // --------------------------------------------------------------------------- // OS service backends for the supervised Executor daemon. // // The long-lived gateway must outlive the GUI app and survive machine restarts. // That means the OS service manager — not a foreground process — owns its // lifecycle: launchd on macOS, systemd --user on Linux, Task Scheduler on // Windows. Each backend registers the SAME running contract: spawn // ` daemon run --foreground --port

`, bind loopback, write // `server.json`, and get restarted on crash but not on a clean stop. // // By default each backend starts the daemon at LOGIN with no elevation: launchd // RunAtLoad, systemd --user, and a Windows per-user logon Scheduled Task. Where // the platform can also survive a reboot before any login, that is layered on // best-effort (Linux lingering) or behind an explicit, elevation-requiring opt // in (Windows `--boot`: a BootTrigger/S4U task). Windows registration goes // through `schtasks.exe` (Task Scheduler RPC), not the CIM `*-ScheduledTask` // cmdlets, so a non-admin install from the compiled binary is not blocked by a // DCOM local-activation denial. // --------------------------------------------------------------------------- export const SERVICE_LABEL = "sh.executor.daemon"; /** * The supervised service binds this port by default. It matches the desktop * connect-card port (4789, not the `executor daemon run` default of 4788) so * existing desktop MCP-client configs keep resolving. The exact value is * low-stakes: clients discover the live port from `server.json`. */ export const DEFAULT_SERVICE_PORT = 4789; export interface ServiceDescriptor { /** Absolute path to the `executor` binary the service should run. */ readonly executablePath: string; readonly port: number; /** Installing CLI version, baked in for drift detection on upgrade. */ readonly version: string; /** * Opt in to boot-survival-before-login (Windows only, requires elevation). * Default (false) installs a per-user, login-triggered task that needs no * Administrator shell, matching launchd RunAtLoad and systemd --user. When * true the Windows backend registers an AtStartup/S4U task that runs at boot * before any logon, which Task Scheduler only lets an elevated shell create. * launchd and systemd ignore this flag (they always start at login, with * Linux lingering best-effort upgrading that to boot). */ readonly boot?: boolean; } // No secret is part of the descriptor: the supervised daemon mints/loads its // bearer token from the 0600 `auth.json` (under EXECUTOR_DATA_DIR) on start, and // clients read the same file. Keeping the secret out of the plist/unit means // `launchctl print`/`list` and `systemctl cat` never expose it. export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported"; export interface ServiceStatus { readonly platform: ServicePlatform; /** The OS manager has a unit/plist/task on disk for the service. */ readonly registered: boolean; /** The OS manager reports the service currently loaded/active. */ readonly running: boolean; readonly pid: number | null; /** Extra human-readable lines (e.g. manual steps on unsupported platforms). */ readonly detail: ReadonlyArray; } export interface ServiceBackend { readonly platform: ServicePlatform; /** True when this backend actually drives the OS manager (vs. printing steps). */ readonly automated: boolean; readonly install: ( descriptor: ServiceDescriptor, ) => Effect.Effect; readonly uninstall: () => Effect.Effect< void, Error | PlatformError, FileSystem.FileSystem | Path.Path >; readonly status: () => Effect.Effect< ServiceStatus, Error | PlatformError, FileSystem.FileSystem | Path.Path >; readonly restart: () => Effect.Effect; } // --------------------------------------------------------------------------- // Process helper — run an OS command and capture (stdout, stderr, exit code). // Resolves on a non-zero exit so callers can branch; fails only when the // command itself cannot be spawned (e.g. launchctl missing). // --------------------------------------------------------------------------- interface CommandResult { readonly stdout: string; readonly stderr: string; readonly code: number; } const runCommand = ( cmd: string, args: ReadonlyArray, env?: Record, ): Effect.Effect => Effect.callback((resume) => { const options = env ? { encoding: "utf8" as const, env: { ...process.env, ...env } } : { encoding: "utf8" as const }; execFile(cmd, [...args], options, (error, stdout, stderr) => { // A string `code` (ENOENT etc.) means the command could not be spawned. if (error && typeof (error as { code?: unknown }).code === "string") { resume( Effect.fail(new Error(`Failed to run \`${cmd}\`: ${(error as { code: string }).code}`)), ); return; } const code = error && typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 0; resume(Effect.succeed({ stdout: stdout ?? "", stderr: stderr ?? "", code })); }); }); const currentUid = (): number => { const getuid = (process as { getuid?: () => number }).getuid; if (typeof getuid === "function") return getuid.call(process); return userInfo().uid; }; const xmlEscape = (value: string): string => value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); const xmlUnescape = (value: string): string => value .replaceAll("'", "'") .replaceAll(""", '"') .replaceAll(">", ">") .replaceAll("<", "<") .replaceAll("&", "&"); // --------------------------------------------------------------------------- // Shared service environment + program args // --------------------------------------------------------------------------- const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray => [ descriptor.executablePath, "daemon", "run", "--foreground", "--port", String(descriptor.port), "--hostname", "127.0.0.1", ]; const serviceEnvironment = ( descriptor: ServiceDescriptor, dataDir: string, ): Record => { const passThroughKeys = [ "EXECUTOR_CLIENT", "EXECUTOR_SENTRY_DSN", "EXECUTOR_SENTRY_RELEASE", "EXECUTOR_SENTRY_ENVIRONMENT", "EXECUTOR_RUN_ID", // Analytics opt-out must survive into the supervised unit's minimal env, // or an opted-out install would silently re-enable analytics under launchd. "DO_NOT_TRACK", "EXECUTOR_DISABLE_ANALYTICS", // Preserve the caller's TLS trust configuration. Corporate/intercepting // CAs commonly live outside the OS trust store; dropping these paths in a // minimal launchd/systemd environment makes HTTPS integrations fail only // after service installation while the same CLI call succeeds. "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR", ] 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) // --------------------------------------------------------------------------- const launchAgentsDir = (path: Path.Path): string => path.join(homedir(), "Library", "LaunchAgents"); const launchdPlistPath = (path: Path.Path): string => path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`); const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop"; const enclosingAppBundlePath = (executablePath: string): string | null => { const match = executablePath.match(/^(.*\.app)\/Contents\//); return match?.[1] ?? null; }; const readBundleIdentifier = (appBundlePath: string): string | null => { try { const plist = readFileSync(`${appBundlePath}/Contents/Info.plist`, "utf8"); const match = plist.match(/\s*CFBundleIdentifier\s*<\/key>\s*([^<]*)<\/string>/); const bundleIdentifier = match ? xmlUnescape(match[1].trim()) : ""; return bundleIdentifier.length > 0 ? bundleIdentifier : null; } catch { return null; } }; const associatedBundleIdentifier = (programArguments: ReadonlyArray): string | null => { const executablePath = programArguments[0]; if (!executablePath) return null; const appBundlePath = enclosingAppBundlePath(executablePath); if (!appBundlePath) return null; const bundleIdentifier = readBundleIdentifier(appBundlePath); if (bundleIdentifier) return bundleIdentifier; return /(?:^|\/)Executor\.app\/Contents\//.test(executablePath) ? DESKTOP_APP_BUNDLE_IDENTIFIER : null; }; export interface LaunchdPlistOptions { readonly label: string; readonly programArguments: ReadonlyArray; readonly environment: Record; readonly stdoutPath: string; readonly stderrPath: string; readonly workingDirectory: string; } /** * Render a user LaunchAgent plist. KeepAlive uses * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background * keeps it off the foreground scheduler. */ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { const programArgs = options.programArguments .map((arg) => ` ${xmlEscape(arg)}`) .join("\n"); const bundleIdentifier = associatedBundleIdentifier(options.programArguments); const associatedBundleIdentifiers = bundleIdentifier ? ` AssociatedBundleIdentifiers\n \n ${xmlEscape(bundleIdentifier)}\n \n` : ""; const envEntries = Object.entries(options.environment) .map( ([key, value]) => ` ${xmlEscape(key)}\n ${xmlEscape(value)}`, ) .join("\n"); return ` Label ${xmlEscape(options.label)} ${associatedBundleIdentifiers} ProgramArguments ${programArgs} EnvironmentVariables ${envEntries} RunAtLoad KeepAlive SuccessfulExit ProcessType Background WorkingDirectory ${xmlEscape(options.workingDirectory)} StandardOutPath ${xmlEscape(options.stdoutPath)} StandardErrorPath ${xmlEscape(options.stderrPath)} `; }; const parseLaunchctlPid = (printOutput: string): number | null => { const match = printOutput.match(/\bpid\s*=\s*(\d+)/); if (!match) return null; const pid = Number.parseInt(match[1], 10); return Number.isInteger(pid) && pid > 0 ? pid : null; }; const makeLaunchdBackend = (): ServiceBackend => { const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`; return { platform: "darwin", automated: true, install: (descriptor) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const uid = currentUid(); const dataDir = resolveExecutorDataDir(path); const logs = serviceLogDir(path); yield* fs.makeDirectory(launchAgentsDir(path), { recursive: true }); yield* fs.makeDirectory(logs, { recursive: true }); const plist = generateLaunchdPlist({ label: SERVICE_LABEL, programArguments: serviceProgramArguments(descriptor), environment: serviceEnvironment(descriptor, dataDir), stdoutPath: path.join(logs, "daemon.log"), stderrPath: path.join(logs, "daemon.error.log"), workingDirectory: dataDir, }); const plistFile = launchdPlistPath(path); // 0600: the plist is owner-only. It carries no secret — the daemon reads // the bearer from auth.json at boot — but stays tight regardless. 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". `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( new Error( `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`, ), ); } }), uninstall: () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const uid = currentUid(); yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); yield* runCommand("launchctl", ["disable", serviceTarget(uid)]).pipe(Effect.ignore); yield* fs.remove(launchdPlistPath(path), { force: true }); }), status: () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const uid = currentUid(); const registered = yield* fs.exists(launchdPlistPath(path)); const print = yield* runCommand("launchctl", ["print", serviceTarget(uid)]); const running = print.code === 0; return { platform: "darwin" as const, registered, running, pid: running ? parseLaunchctlPid(print.stdout) : null, detail: [], }; }), restart: () => Effect.gen(function* () { const uid = currentUid(); const result = yield* runCommand("launchctl", ["kickstart", "-k", serviceTarget(uid)]); if (result.code !== 0) { return yield* Effect.fail( new Error( `launchctl kickstart failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`, ), ); } }), }; }; // --------------------------------------------------------------------------- // Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM) // --------------------------------------------------------------------------- const systemdUnitDir = (path: Path.Path): string => path.join(homedir(), ".config", "systemd", "user"); const systemdUnitPath = (path: Path.Path): string => path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`); export interface SystemdUnitOptions { readonly execStart: ReadonlyArray; readonly environment: Record; readonly workingDirectory: string; readonly stdoutPath: string; 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(systemdQuote).join(" "); const env = Object.entries(options.environment) .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) .join("\n"); return `[Unit] Description=Executor supervised daemon After=default.target [Service] Type=simple ExecStart=${execStart} ${env} WorkingDirectory=${systemdQuote(options.workingDirectory)} StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)} StandardError=${systemdQuote(`append:${options.stderrPath}`)} Restart=on-failure RestartSec=5s [Install] WantedBy=default.target `; }; const makeSystemdBackend = (): ServiceBackend => { const unitName = `${SERVICE_LABEL}.service`; return { platform: "linux", automated: true, install: (descriptor) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dataDir = resolveExecutorDataDir(path); const logs = serviceLogDir(path); yield* fs.makeDirectory(systemdUnitDir(path), { recursive: true }); yield* fs.makeDirectory(logs, { recursive: true }); const unit = generateSystemdUnit({ execStart: serviceProgramArguments(descriptor), environment: serviceEnvironment(descriptor, dataDir), workingDirectory: dataDir, stdoutPath: path.join(logs, "daemon.log"), stderrPath: path.join(logs, "daemon.error.log"), }); yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 }); // `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply // it if the caller's environment lacks it (e.g. a non-login shell) so // install is robust regardless of how it was invoked. const username = userInfo().username; const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, }; yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); const enable = yield* runCommand( "systemctl", ["--user", "enable", "--now", unitName], sdEnv, ); if (enable.code !== 0) { return yield* Effect.fail( new Error( `systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`, ), ); } // Enable lingering so the user manager — and this enabled service — // starts at BOOT, not just on login, so the daemon survives a reboot // unattended (verified in a real Ubuntu VM via loginctl). Best-effort: // if the platform needs privilege, the service still works for the // logged-in case and `service status` flags the missing linger. yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore); }), uninstall: () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, }; yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe( Effect.ignore, ); yield* fs.remove(systemdUnitPath(path), { force: true }); yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe( Effect.ignore, ); }), status: () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, }; const registered = yield* fs.exists(systemdUnitPath(path)); const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv); const running = active.stdout.trim() === "active"; const linger = yield* runCommand("loginctl", [ "show-user", userInfo().username, "-p", "Linger", "--value", ]); const lingerOn = linger.stdout.trim() === "yes"; return { platform: "linux" as const, registered, running, pid: null, detail: lingerOn ? [] : [ "Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.", ], }; }), restart: () => Effect.gen(function* () { const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, }; const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv); if (result.code !== 0) { return yield* Effect.fail( new Error( `systemctl --user restart failed (exit ${result.code}): ${result.stderr.trim()}`, ), ); } }), }; }; // --------------------------------------------------------------------------- // Windows — Task Scheduler (S4U / AtStartup; reboot-survival verified) // --------------------------------------------------------------------------- /** Scheduled Task name registered for the supervised daemon. */ export const WINDOWS_TASK_NAME = "ExecutorDaemon"; /** * Make a value safe to embed in a cmd.exe `set "KEY=VALUE"` line. A literal `"` * would close the quoted argument early — in PATH (built from arbitrary * installer entries) that lets `& cmd &` fragments execute when Task Scheduler * runs the wrapper at boot, so strip them (a `"` is illegal in a Windows path * anyway). A literal `%` is re-expanded by cmd at run time against the boot * environment, silently diverging from the value captured at install; double it * so the daemon sees exactly what was captured. */ export const cmdSetValue = (value: string): string => value.replaceAll('"', "").replaceAll("%", "%%"); /** * The batch wrapper the Scheduled Task executes. Task Scheduler has no field * for environment variables, so the supervised env (EXECUTOR_SUPERVISED, data * dir, version, PATH) is baked into the wrapper as `set` lines before it execs * the daemon. stdout/stderr append to the same log files the other backends * use. CRLF line endings keep it a well-formed `.cmd`. */ export const generateWindowsDaemonWrapper = ( descriptor: ServiceDescriptor, dataDir: string, logDir: string, ): string => { const env = serviceEnvironment(descriptor, dataDir); const setLines = Object.entries(env).map(([key, value]) => `set "${key}=${cmdSetValue(value)}"`); const [exe, ...rest] = serviceProgramArguments(descriptor); const command = `"${exe}" ${rest.join(" ")} 1>> "${logDir}\\daemon.log" 2>> "${logDir}\\daemon.error.log"`; return ["@echo off", ...setLines, command, ""].join("\r\n"); }; /** * The Scheduled Task is registered for this exact account. We pass the * fully-qualified `DOMAIN\user` (really `COMPUTERNAME\user` on a standalone box) * so Task Scheduler resolves the principal as the caller and lets a non-admin * register a task that runs as themselves. The bare username also works, but the * qualified form is unambiguous when the machine is domain-joined. */ const windowsTaskUserId = (): string => { const name = userInfo().username; const domain = process.env.USERDOMAIN; return domain ? `${domain}\\${name}` : name; }; /** * Render the Task Scheduler task definition (Task Scheduler 1.2 XML, the schema * `schtasks /create /xml` consumes). Two shapes, same running contract * (RestartOnFailure gives crash-restart; ExecutionTimeLimit=PT0S means "never * time out a long-running task"): * * - `boot: false` (DEFAULT, no Administrator needed): a LogonTrigger for this * user + an InteractiveToken principal at LeastPrivilege. A standard user is * allowed to register a task that runs as themselves at their own logon, so * this installs from an ordinary, non-elevated shell. It is the Windows * equivalent of a launchd LaunchAgent with RunAtLoad and `systemd --user`: * the daemon comes up when the user logs in, unprivileged. * * - `boot: true` (requires an elevated/Administrator shell): a BootTrigger + an * S4U principal at HighestAvailable. Runs the daemon AS THE USER at boot, with * no stored password and no interactive logon — survives a real reboot with no * login on a headless host. Task Scheduler only lets an elevated shell create * a boot/S4U task, which is why this path costs the UAC prompt. * * We register via `schtasks.exe` rather than the PowerShell `*-ScheduledTask` * cmdlets on purpose: those cmdlets reach Task Scheduler over CIM/DCOM, whose * local-activation check fails with "Access denied" (0x80070005) when the * compiled `executor` binary spawns the helper on a non-interactive window * station. `schtasks` talks to the Task Scheduler service over RPC, so it has no * such dependency and a non-admin install succeeds. */ export const generateWindowsTaskXml = (options: { readonly command: string; readonly arguments?: string; readonly userId: string; readonly boot?: boolean; }): string => { const user = xmlEscape(options.userId); const trigger = options.boot ? "true" : `true${user}`; const logonType = options.boot ? "S4U" : "InteractiveToken"; const runLevel = options.boot ? "HighestAvailable" : "LeastPrivilege"; const action = options.arguments ? `${xmlEscape(options.command)}${xmlEscape(options.arguments)}` : `${xmlEscape(options.command)}`; return [ '', '', " Executor supervised daemon", ` ${trigger}`, " ", ` ${user}${logonType}${runLevel}`, " ", " ", " IgnoreNew", " false", " false", " true", " PT1M3", " PT0S", " true", " ", ' ', ` ${action}`, " ", "", "", ].join("\r\n"); }; /** * A tiny VBScript shim the task runs via `wscript.exe`. The default (logon) task * runs in the user's interactive session, so pointing its action straight at the * `.cmd` wrapper would flash a console window on the desktop at every login. The * shim launches the wrapper with a hidden window (`Run(cmd, 0, True)`) and waits * for it, so: no visible window, and `wscript` stays alive for the daemon's * lifetime, which keeps the task reported as Running and preserves RestartOnFailure * (the wrapper's exit code propagates out). Output still flows to daemon.log via * the wrapper's own redirection. */ export const generateWindowsHiddenLauncherVbs = (wrapperPath: string): string => [ 'Set sh = CreateObject("WScript.Shell")', `rc = sh.Run("""${wrapperPath}""", 0, True)`, "WScript.Quit rc", "", ].join("\r\n"); /** Run schtasks.exe, capturing (stdout, stderr, code). */ const runSchtasks = (args: ReadonlyArray): Effect.Effect => runCommand("schtasks.exe", args); /** Write a UTF-16LE (BOM-prefixed) file — the encoding schtasks expects for XML. */ const writeUtf16File = ( path: string, contents: string, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const bytes = Buffer.from(`${contents}`, "utf16le"); yield* fs.writeFile(path, new Uint8Array(bytes)); }); /** * SCHED_S_TASK_RUNNING — the Task Scheduler HRESULT a task reports as its "Last * Result" while it is currently running (0x00041301 == 267009). schtasks prints * this in the verbose listing as a decimal; some Windows builds/locales print the * hex form. The numeric code is locale-invariant even though the surrounding * labels ("Last Result:", "Status:") and the human state word ("Running") are * translated on a non-English Windows. */ const SCHED_S_TASK_RUNNING = 267009; /** * Decide whether `schtasks /query /v /fo LIST` reports the task as currently * running, without depending on the localized "Status:" line. We look for the * locale-invariant SCHED_S_TASK_RUNNING result code instead. Pure + exported for * unit tests (including non-English fixtures). */ export const parseSchtasksRunning = (verboseListOutput: string): boolean => new RegExp(`\\b(?:${SCHED_S_TASK_RUNNING}|0x0*41301)\\b`, "i").test(verboseListOutput); /** * Parse `netstat -ano` output for the PIDs listening on `port`. Pure, so it can * be unit-tested without a live socket. Matches both IPv4 (`127.0.0.1:PORT`) and * IPv6 (`[::1]:PORT`) local endpoints. A listener is identified by its * wildcard/zero remote endpoint (`0.0.0.0:0` / `[::]:0`) rather than the state * column, because that column ("LISTENING") is localized on a non-English * Windows while the addresses and the `TCP` token are not. */ const NETSTAT_LISTENER_REMOTES = new Set(["0.0.0.0:0", "[::]:0", "*:*"]); export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray => { const pids = new Set(); for (const line of output.split(/\r?\n/)) { const cols = line.trim().split(/\s+/); // TCP if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; if (!cols[1].endsWith(`:${port}`)) continue; if (!NETSTAT_LISTENER_REMOTES.has(cols[2])) continue; const pid = Number.parseInt(cols[4], 10); if (Number.isInteger(pid) && pid > 0) pids.add(pid); } return [...pids]; }; /** * Stop orphaned Windows `executor.exe` listeners on the service port. Task * Scheduler can report the task as stopped after terminating the `.cmd` action * while leaving the child `executor.exe` process bound to the port. That process * is healthy enough to satisfy `/api/health`, but `executor web` cannot discover * it once its `server.json` is gone. We use netstat/tasklist/taskkill (RPC/Win32 * tools) rather than the CIM `Get-NetTCPConnection`/`Get-CimInstance` cmdlets so * this works from the compiled binary without a DCOM local-activation grant. */ export const stopWindowsExecutorListenersOnPort = ( port: number, ): Effect.Effect, Error> => Effect.gen(function* () { const netstat = yield* runCommand("netstat.exe", ["-ano", "-p", "tcp"]); if (netstat.code !== 0) { const detail = netstat.stderr.trim() || netstat.stdout.trim(); return yield* Effect.fail( new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`), ); } const stopped: number[] = []; for (const pid of parseNetstatListenerPids(netstat.stdout, port)) { // Confirm the listener really is executor.exe before killing it. const list = yield* runCommand("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]); if (!/"executor\.exe"/i.test(list.stdout)) continue; const kill = yield* runCommand("taskkill.exe", ["/PID", String(pid), "/F"]); if (kill.code === 0) stopped.push(pid); } return stopped; }); const makeWindowsBackend = (): ServiceBackend => { const controlDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "server-control"); const taskXmlPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.xml"); const wrapperCmdPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.cmd"); const launcherVbsPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.vbs"); return { platform: "win32", automated: true, install: (descriptor) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dataDir = resolveExecutorDataDir(path); const logs = serviceLogDir(path); const control = path.join(dataDir, "server-control"); yield* fs.makeDirectory(logs, { recursive: true }); yield* fs.makeDirectory(control, { recursive: true }); const wrapperPath = wrapperCmdPath(path); yield* fs.writeFileString( wrapperPath, generateWindowsDaemonWrapper(descriptor, dataDir, logs), ); // Launch the wrapper through a hidden wscript shim so the logon task // doesn't flash a console window on the interactive desktop each login. const vbsPath = launcherVbsPath(path); yield* fs.writeFileString(vbsPath, generateWindowsHiddenLauncherVbs(wrapperPath)); const xmlPath = taskXmlPath(path); yield* writeUtf16File( xmlPath, generateWindowsTaskXml({ command: "wscript.exe", arguments: `"${vbsPath}"`, userId: windowsTaskUserId(), boot: descriptor.boot ?? false, }), ); const create = yield* runSchtasks([ "/create", "/tn", WINDOWS_TASK_NAME, "/xml", xmlPath, "/f", ]); if (create.code !== 0) { // schtasks ends its messages with a period; drop it so the assembled // sentence doesn't read "denied.. ". const detail = (create.stderr.trim() || create.stdout.trim()).replace(/\.\s*$/, ""); // The default (logon) task needs no elevation. Only the --boot path // registers a boot/S4U task, which Task Scheduler refuses without // Administrator — so point access-denial at the relevant fix. const hint = /denied|0x80070005|administrator|elevat/i.test(detail) ? descriptor.boot ? " `--boot` registers a boot task, which needs an Administrator shell. Re-run elevated, or drop `--boot` to install a no-elevation login task." : " Run `executor service install` from an Administrator shell." : ""; return yield* Effect.fail( new Error(`schtasks /create failed (exit ${create.code}): ${detail}.${hint}`), ); } // Bring it up now (the other backends start on install too). A logon task // /run launches in the current interactive session; tolerate a non-zero // here (e.g. no interactive session yet) since the trigger still fires on // the next logon and `service status` reports the registered task. yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); }), uninstall: () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // Idempotent: tolerate "task not found" but warn if schtasks can't run. yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); yield* runSchtasks(["/delete", "/tn", WINDOWS_TASK_NAME, "/f"]).pipe( Effect.tapError((cause) => Effect.sync(() => console.warn( `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, ), ), ), Effect.ignore, ); yield* fs.remove(wrapperCmdPath(path), { force: true }); yield* fs.remove(launcherVbsPath(path), { force: true }); yield* fs.remove(taskXmlPath(path), { force: true }); }), status: () => Effect.gen(function* () { const result = yield* runSchtasks([ "/query", "/tn", WINDOWS_TASK_NAME, "/fo", "LIST", "/v", ]); if (result.code !== 0) { return { platform: "win32" as const, registered: false, running: false, pid: null, detail: [ "No ExecutorDaemon scheduled task registered. Run `executor service install`.", ], }; } // Detect "running" via the locale-invariant SCHED_S_TASK_RUNNING result // code, not the translated "Status: Running" line (which is localized on // a non-English Windows and would otherwise always read as not-running). const running = parseSchtasksRunning(result.stdout); return { platform: "win32" as const, registered: true, running, pid: null, detail: running ? [] : ["Scheduled task registered but not currently running."], }; }), restart: () => Effect.gen(function* () { yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); const run = yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]); if (run.code !== 0) { return yield* Effect.fail( new Error( `Failed to restart ExecutorDaemon task (exit ${run.code}): ${run.stderr.trim() || run.stdout.trim()}`, ), ); } }), }; }; const makeUnsupportedBackend = (): ServiceBackend => ({ platform: "unsupported", automated: false, install: () => Effect.fail(new Error(`OS service install is not supported on ${process.platform}.`)), uninstall: () => Effect.fail(new Error(`OS service uninstall is not supported on ${process.platform}.`)), status: () => Effect.succeed({ platform: "unsupported" as const, registered: false, running: false, pid: null, detail: [`OS service management is not supported on ${process.platform}.`], }), restart: () => Effect.fail(new Error(`OS service restart is not supported on ${process.platform}.`)), }); /** Select the service backend for the current OS. */ export const getServiceBackend = (platform: NodeJS.Platform = process.platform): ServiceBackend => { switch (platform) { case "darwin": return makeLaunchdBackend(); case "linux": return makeSystemdBackend(); case "win32": return makeWindowsBackend(); default: return makeUnsupportedBackend(); } };