/** * Installs, upgrades, checks, and removes failproofaid as a system-managed * service. The definition is root-owned and starts at boot, but the daemon * process itself runs as the user who configured failproofai. * * No public `failproofai daemon install`-style subcommand exists — * `configure-wizard.ts` calls the functions here directly, the same * relationship it already has with `manager.ts`'s `installHooks()`. */ import { HEALTH_PROBE_SOURCE } from "./health-probe"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, rmSync, } from "node:fs"; import { homedir, tmpdir, userInfo } from "node:os"; import { dirname, resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { hookLogWarn } from "./hook-logger"; import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download"; import { logsDir } from "./fp-home"; import { shellQuote } from "./shell-quote"; import { version } from "../../package.json"; import { readVersionFile, updateConfig, writeVersionFile } from "./fp-config"; /** * Every `systemctl` / `launchctl` call is bounded. A wedged service manager * makes an * unbounded `execFileSync` block forever — inside the interactive wizard * that reads as a hang with no output at all (`stdio: "ignore"`), right * after the user pressed "apply". A timeout throws instead, which the * existing `catch` already turns into a clean `{ installed: false, reason }`. */ const SERVICE_CMD_TIMEOUT_MS = 10_000; /** * How long to wait for the service manager to actually get the daemon into * a running state after restart/load. Those commands return as * soon as the job is accepted, which is well before the process has proven * it can stay up. */ const SERVICE_START_TIMEOUT_MS = 5_000; const SERVICE_START_POLL_MS = 100; /** * How long a unit has to still be running after it first reports running. * `systemctl is-active` calls a `Type=simple` unit active the moment * it forks, so a daemon that dies immediately still reports active once — * a single check would wave through exactly the crash-at-startup case this * is here to catch. Comfortably longer than the unit's `RestartSec=2` * head start, so a daemon that already died reads as * `activating (auto-restart)` by the time of the re-check. */ const SERVICE_SETTLE_MS = 750; /** * How long `sudo -v` may sit at its password prompt. Unlike every other * timeout here, this one is waiting on a human typing, not on a service * manager — three attempts at a password is well inside two minutes, and * sudo gives up on its own long before that. */ const SUDO_PROMPT_TIMEOUT_MS = 120_000; /** * Writes (or clears) the machine-wide `daemonConfigured` marker in * `~/.failproofai/policies-config.json`. * * This flag is what makes `bin/failproofai.mjs` fail *closed* — deny every * hook event rather than fall back to in-process evaluation — so it has to * track "this machine has a daemon", not "a service manager once accepted a * job". Install only sets it after the daemon is verified running; uninstall * clears it, so removing the service restores the in-process path instead of * leaving the machine denying every tool call across all 11 CLIs against a * socket that no longer exists. * * Global scope only: whether *this machine* runs a daemon is not a * per-project setting. */ export function setDaemonConfigured(value: boolean, installedVersion?: string): void { try { updateConfig({ daemon: { configured: value } }); // The version lives in VERSION, not here. One file is ABOUT versions and // one copy cannot disagree with itself; keeping a second in config.toml // would be a field that drifts out of step with the file beside it — // exactly the class of bug the single path module exists to prevent. // Cleared on uninstall: a recorded binary version for a service that is no // longer configured is a claim about this machine that is no longer true. writeVersionFile({ daemon: value ? installedVersion : undefined, clearDaemon: !value }); } catch { /* best-effort: never fail a completed setup (or uninstall) over this flag */ } } export type DaemonServiceStatus = | "running" | "stopped" /** * Installed, but systemd evaluated its `ConditionPathExists=` and refused to * start it — the daemon binary or the worker script is gone. * * Deliberately its own state rather than folded into "stopped". "Stopped" is * ambiguous on purpose (a restart in flight looks identical), which is why * nothing is allowed to act destructively on it. A failed condition carries * no such ambiguity: systemd has already decided this unit will not run, and * will keep deciding that at every boot until the missing path returns. That * is provable enough to clear `daemonConfigured` on, and leaving it * indistinguishable from a transient stop is what kept a machine denying * every tool call with no explanation. */ | "condition-failed" | "not-installed" /** * Installed, and its state could not be READ — distinct from any claim about * whether it is running. * * Only macOS reaches this. A LaunchDaemon lives in launchd's system domain, * so unlike `systemctl is-active` (which any user can run against a system * unit) reading its state needs the same elevation installing it did, and the * CLI will not prompt for a password from under a TUI. An expired sudo cache * — five minutes by default — therefore means "cannot check", and folding * that into "stopped" made a perfectly healthy daemon look dead: the wizard * then demanded sudo and bounced the service, which on a `daemonConfigured` * machine is a real fail-closed window, to fix nothing. Callers that need the * truth should ask the daemon itself (`probeDaemonEndToEnd`), which needs no * privileges at all. */ | "unknown" | "unsupported-platform"; /** Linux + macOS only, per the plan's platform scope — full stop. */ export function isDaemonSupportedPlatform(): boolean { return process.platform === "linux" || process.platform === "darwin"; } export type PlatformKey = "linux-x64" | "linux-arm64" | "darwin-x64" | "darwin-arm64"; function platformKey(): PlatformKey | null { const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : null; const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null; if (!os || !arch) return null; return `${os}-${arch}` as PlatformKey; } /** * Locates the real, compiled `failproofaid` binary — never the JS bin * shim (`bin/failproofaid-shim.mjs`), which only exists so a user can run * `failproofaid` by hand. A service manager needs a direct path to the * actual native binary, not a wrapper it would have to keep alive itself. * * Resolution order: an explicit test/dev override, the binary downloaded * from this version's GitHub Release (see `daemon-download.ts`), then a * locally-built dev binary under `target/{release,debug}/failproofaid` * relative to the package root — so this works when driving the wizard from * a source checkout (`bun run daemon:dev`-style flows) with nothing * downloaded at all. * * Read-only by design: it reports what is already on disk and never fetches. * `ensureFailproofaidBinary` is the one that may reach the network, so the * hook path — which calls this — can never block on a download. */ export function resolveFailproofaidBinaryPath(): string | null { if (process.env.FAILPROOFAI_DAEMON_BINARY) return process.env.FAILPROOFAI_DAEMON_BINARY; const downloaded = installedBinaryPath(); if (existsSync(downloaded)) return downloaded; const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; if (packageRoot) { for (const profile of ["release", "debug"]) { const candidate = resolve(packageRoot, "target", profile, "failproofaid"); if (existsSync(candidate)) return candidate; } } return null; } /** * Resolves the binary, installing one if it is not already on disk. * * Only the install path calls this — `failproofai config` choosing the global * scope is the moment a machine that opted into a daemon actually acquires * one. Two channels, in this order: * * 1. The `@failproofai/failproofaid--` platform package, which a * plain `npm install failproofai` already brought down as an optional * dependency. No network, so it is also the only channel that works * air-gapped or behind a proxy that blocks github.com. * 2. The GitHub Release asset for this exact version, checksum-verified. * Covers installs that skipped optional dependencies, older tarballs, and * anyone installing the daemon standalone. */ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> { const existing = resolveFailproofaidBinaryPath(); if (existing) return { path: existing }; const key = platformKey(); if (!key) { return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` }; } const fromNpm = installFromNpmPackage(key); if (fromNpm.path) return { path: fromNpm.path }; const result = await downloadFailproofaidBinary(key); if (result.path) return { path: result.path }; // Both channels are worth reporting: "not installed" alone reads as a broken // package, and the download error alone hides that npm could have supplied it. return { reason: result.error ? `${result.error} (${fromNpm.error})` : "failproofaid binary could not be installed", }; } /** * Resolves the command the daemon should use to spawn its warm worker, * passed through as `FAILPROOFAI_WORKER_CMD` in the service's own * environment. * * This is NOT optional the way it might look — `crates/failproofaid`'s * built-in fallback (`node dist/worker.mjs`, used only when * `FAILPROOFAI_WORKER_CMD` is unset) is a *relative* path, correct only * when the daemon happens to be spawned with the npm package's own * directory as its cwd. A service manager spawns processes from an * arbitrary cwd (typically `/` or the user's home), so that fallback * would silently fail to find the bundled worker in every real service * install — caught by actually starting the installed service in a clean * container rather than by reasoning about it. Resolving an absolute path * here, once, at install time, and handing it to the daemon via the * environment closes that gap entirely. */ export function resolveWorkerCommand(): string | null { if (process.env.FAILPROOFAI_WORKER_CMD) return process.env.FAILPROOFAI_WORKER_CMD; const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; if (!packageRoot) return null; const workerScript = resolve(packageRoot, "dist", "worker.mjs"); if (!existsSync(workerScript)) return null; // Both paths are shell-quoted: the daemon runs this value through `sh -c` // (`WorkerCommand::Shell` in crates/failproofaid/src/worker.rs), so an // unquoted path splits on its spaces and the worker never starts. Not // hypothetical on macOS, where `/Users/First Last/...` is ordinary — and // more exposed since execPath replaced a bare `node`, because execPath is // home-derived. systemd's own `Environment="..."` quoting does not help // here; that protects the unit parse, not the later shell split. // // `process.execPath`, not a bare `node`. A system-scope service does not // inherit a login environment, so its PATH is the system default — and the // single most common way to install Node is nvm, which puts it under // ~/.nvm/versions/node/*/bin and nowhere on that PATH. A bare `node` would // resolve fine when the wizard runs it and then fail inside the service, // silently, on exactly the machines least likely to notice. execPath is // whatever runtime is executing this CLI right now (node for the published // bin, bun in a source checkout), absolute either way. return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`; } /** * Resolves the command the daemon should use to run a one-shot `failproofai` * CLI task — today the scheduled audit — passed through as * `FAILPROOFAI_CLI_CMD` in the service's own environment. * * Everything `resolveWorkerCommand` says about `process.execPath` and the * shell quoting applies here verbatim, for the same reason: a system-scope * unit gets no login environment, so its PATH is the system default, and the * single most common Node install (nvm) is on no system PATH. The difference * is the consequence of getting it wrong. A worker that cannot start makes the * daemon visibly unhealthy; an audit that cannot start is SILENT — the config * says the scan is on, nothing ever runs, and the only symptom is a dashboard * that quietly stops moving. * * `dist/cli.mjs` (package.json's `bin.failproofai`), not `bin/failproofai.mjs` * — the latter has a `#!/usr/bin/env bun` shebang and uses syntax node cannot * load (a bare `import … from "../package.json"`, extensionless `.ts` * specifiers). Only the bundle is node-runnable, and it sits beside the * `dist/worker.mjs` the worker command already points at. */ export function resolveCliCommand(): string | null { if (process.env.FAILPROOFAI_CLI_CMD) return process.env.FAILPROOFAI_CLI_CMD; const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; if (!packageRoot) return null; const cliScript = resolve(packageRoot, "dist", "cli.mjs"); if (!existsSync(cliScript)) return null; return `${shellQuote(process.execPath)} ${shellQuote(cliScript)}`; } /** * The raw, unquoted path to the worker script the service will run — the same * file `resolveWorkerCommand` builds its shell command around. * * It exists separately because the unit needs the path as a PATH (for * `ConditionPathExists=`), not as a shell word, and recovering one from the * other means unparsing POSIX quoting for a value we already had. * * Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary * shell command — a wrapper script, an interpreter with flags, `exec`ing * something else entirely — and guessing which token in it is "the file that * must exist" would gate the service on a path nobody promised. An absent * condition is the correct answer to a question we cannot answer. */ export function workerScriptPath(): string | null { if (process.env.FAILPROOFAI_WORKER_CMD) return null; const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; if (!packageRoot) return null; const workerScript = resolve(packageRoot, "dist", "worker.mjs"); return existsSync(workerScript) ? workerScript : null; } /** * The environment the service definition carries, built once so the systemd * and launchd renderers cannot drift apart — a variable added to one and not * the other is invisible until somebody runs the other platform. * * Order is fixed rather than incidental: `upgradedServiceDefinition` rebuilds * a definition from these renderers, so a stable order is what makes a * rewritten unit byte-identical to a freshly installed one rather than merely * equivalent — which is the difference between a diff a human can read and one * they stop reading. */ /** * Characters that cannot appear in a value interpolated into a systemd unit. * * A newline ENDS THE DIRECTIVE, so a path containing one injects arbitrary * settings into a file that is installed root-owned at `/etc/systemd/system` * and loaded at every boot. A double quote closes `Environment="..."` early and * does the same; a backslash is systemd's own escape character inside a quoted * value and makes the result unpredictable rather than merely wrong. * * The mechanism is demonstrated by this repo's own test, which sets * `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and * relies on systemd HONOURING the injected `User=` — it passes only because the * injected user does not exist. A valid one (`User=root`, or an added * `ExecStartPre=`) would have succeeded silently, undoing the * "root-installed but never root-run" invariant the whole design rests on. * * These values are resolved paths and commands; none can legitimately contain * any of these characters. So this REJECTS rather than escaping — a unit that * refuses to be written is a clear failure, and inventing an escaping scheme * for systemd's grammar is a larger surface than the problem. */ const UNIT_UNSAFE_RE = /["\\\r\n]/; /** Throws when a value cannot be safely interpolated into a unit file. */ function assertUnitSafe(value: string, field: string): string { if (UNIT_UNSAFE_RE.test(value)) { throw new Error( `refusing to write a service definition: ${field} contains a quote, backslash or newline ` + `(${JSON.stringify(value)}), which would inject directives into a root-owned unit file`, ); } return value; } function serviceEnvironment(workerCmd: string | null, cliCmd: string | null): [string, string][] { const entries: [string, string][] = []; if (workerCmd) entries.push(["FAILPROOFAI_WORKER_CMD", workerCmd]); if (cliCmd) entries.push(["FAILPROOFAI_CLI_CMD", cliCmd]); return entries; } /** * The account the daemon runs as. The service is root-*installed* but never * root-*run*: everything it touches (the socket, the lock, the policy config) * lives in one user's home and is peer-checked against that user's uid. */ function serviceUser(): string { return userInfo().username; } /** * `/etc/systemd/system/failproofaid@.service`. * * The `@` suffix is systemd's convention for a per-user instance, but * this is a concrete unit file rather than an instance of a template: every * field that matters is user-specific (the ExecStart path is under the user's * own ~/.failproofai/bin, so is HOME, so is the worker command), so a shared * template would need a per-instance drop-in for all of them and buy nothing. * Naming it per-user is what keeps a second user's install from silently * stealing the first's unit — a single `failproofaid.service` would. */ function systemdUnitName(user: string = serviceUser()): string { return `failproofaid@${user}.service`; } /** * The unit directory, redirectable for tests ONLY. * * It was hardcoded, which left a test asserting "a failed install writes * nothing" checking the REAL `/etc/systemd/system` — so it passed in CI, where * nothing is installed, and failed on the machine of anyone who actually uses * failproofai, whose genuine unit file is sitting right there. That is the bug * class `handler.test.ts`'s header already documents: CI is green, so the red is * only ever seen locally, by exactly the people who most need to trust the suite. * * Read through a function rather than captured at module load, because a test * sets it after importing this module. */ function systemdUnitDir(): string { return process.env.FAILPROOFAI_SYSTEMD_DIR || "/etc/systemd/system"; } function systemdUnitPath(user: string = serviceUser()): string { return resolve(systemdUnitDir(), systemdUnitName(user)); } /** * Where a Linux install puts the copy of the daemon that systemd actually runs: * `/opt/failproofai//bin/failproofaid-`, root-owned. * * The binary is downloaded into `~/.failproofai/bin`, and a unit whose * `ExecStart` points there works on Debian, Ubuntu and Mint — and on Fedora, * RHEL, Rocky and Alma it never starts. Those ship SELinux enforcing, a file * under a home directory is labelled `user_home_t`, and the targeted policy * gives systemd (`init_t`) no execute permission on that type at all. The unit * fails `203/EXEC Permission denied`, `waitForDaemonRunning()` times out, and * `failproofai config` aborts at its very last step. * * The policy's generic `/opt/(.*\/)?bin(/.*)?` rule labels this path `bin_t`, * which systemd may execute, and executing it moves the daemon into * `unconfined_service_t` — the domain that may in turn run the worker's node * from `~/.nvm` (also `user_home_t`) and accept hooks on the socket. So only * the daemon binary moves; the worker command, the socket and the state under * `~/.failproofai` stay where they are. Not `/usr/local/libexec`: both * policies label that `usr_t`, which starts the daemon only through a * secondary `init_t usr_t → unconfined_service_t` rule, where `bin_t` is the * type the policy means for programs. Not `/usr/libexec` either: `/usr` is the * package manager's, and read-only on Silverblue and CoreOS. All checked * against the shipped policy on Fedora 41 and Rocky 9 with `matchpathcon` and * `sesearch`. * * Per user for the same reason the unit name is: two people on one box may run * different versions, and one's uninstall must not delete the other's binary. * The `~/.failproofai/bin` copy is kept — `daemonVersionSkew()` and * `refreshDaemonToCliVersion()` read the installed version from it. * * Redirectable for tests ONLY, like the unit directory. */ function systemBinaryRoot(user: string = serviceUser()): string { return resolve(process.env.FAILPROOFAI_SYSTEM_BIN_DIR || "/opt/failproofai", user); } function systemBinaryDir(): string { return resolve(systemBinaryRoot(), "bin"); } /** The system binary directory on Linux, for the manual-removal instructions. */ export function daemonSystemBinaryDir(): string | null { return process.platform === "linux" ? systemBinaryRoot() : null; } /** * The path systemd should start for `binaryPath`, or null when it should start * `binaryPath` itself. * * Only a real file is copied. `FAILPROOFAI_DAEMON_BINARY` is "someone named a * binary explicitly" and may be a command with arguments (this repo's own * systemd tests use `/usr/bin/sleep infinity`), so it is started as given. */ export function systemBinaryTarget(binaryPath: string): string | null { if (process.platform !== "linux" || process.env.FAILPROOFAI_DAEMON_BINARY) return null; try { if (!statSync(binaryPath).isFile()) return null; } catch { return null; } return resolve(systemBinaryDir(), `failproofaid-${version}`); } /** * Whether this kernel has SELinux enabled. `/sys/fs/selinux` is mounted only * when it is, enforcing or permissive. */ function selinuxEnabled(): boolean { return existsSync("/sys/fs/selinux/enforce"); } /** * Copies the daemon binary into the system directory and returns the path the * unit should start. Privileged; throws when the copy fails, which the caller * reports as a failed install before any unit is written. * * Staged under a temp name and renamed into place, so a reinstall of the same * version never writes into the file a running daemon was started from. * * `restorecon` afterwards, because a new file inherits the label of the * directory it is created in, not the one the policy assigns to its path: the * directories `install -d` creates under `/opt` (`usr_t`) come out `usr_t` * too, never the `bin_t` the policy names for them. */ function stageSystemBinary(binaryPath: string): string { const target = systemBinaryTarget(binaryPath); if (!target) return binaryPath; const dir = systemBinaryDir(); const staging = `${target}.${process.pid}.tmp`; runPrivileged("install", ["-d", "-m", "0755", dir]); try { runPrivileged("install", ["-m", "0755", binaryPath, staging]); runPrivileged("mv", ["-f", staging, target]); } catch (err) { try { runPrivileged("rm", ["-f", staging]); } catch { /* best-effort */ } throw err; } if (selinuxEnabled()) { const root = systemBinaryRoot(); try { runPrivileged("restorecon", ["-R", root]); } catch (err) { // Not fatal here: a wrong label still surfaces as the daemon not // reaching a running state, which fails the install with its status. hookLogWarn(`restorecon on ${root} failed: ${err instanceof Error ? err.message : String(err)}`); } } return target; } /** * Removes system copies other than `current` and the most recent one before * it — the same keep-one-for-rollback rule `pruneOldDaemonBinaries()` applies * to `~/.failproofai/bin`. Best-effort; only called once the service runs * `current`. */ function pruneSystemBinaries(current: string): void { try { const dir = systemBinaryDir(); const stale = readdirSync(dir) .filter((f) => f.startsWith("failproofaid-")) .map((f) => resolve(dir, f)) .filter((p) => p !== current) .map((p) => ({ p, mtime: statSync(p).mtimeMs })) .sort((a, b) => b.mtime - a.mtime) .slice(1); for (const { p } of stale) runPrivileged("rm", ["-f", p]); } catch { // Nothing to prune, or no elevation left — leaving a file costs disk only. } } /** * The pre-1.0.0-beta.1 user-scope unit. Still removed on install and * uninstall: it holds the same flock the new service needs, so leaving one * behind means the system unit starts, loses the singleton race, and the * machine sits fail-closed against a daemon that never came up. */ function legacySystemdUserUnitPath(): string { return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service"); } /** * Per user, for the same reason the systemd unit is: a LaunchDaemon's * contents are entirely user-specific — `UserName`, the ExecStart path under * that user's `~/.failproofai/bin`, the log paths under that user's home — so * one shared label means the second user's install silently overwrites the * first's daemon, and their uninstall deletes it. */ function launchdLabel(user: string = serviceUser()): string { return `ai.failproof.failproofaid.${user}`; } function launchdPlistPath(user: string = serviceUser()): string { return `/Library/LaunchDaemons/${launchdLabel(user)}.plist`; } /** The pre-1.0.0-beta.1 LaunchAgent. */ function legacyLaunchAgentPlistPath(): string { return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist"); } /** * The 1.0.0-beta.1 LaunchDaemon, before the label was namespaced. Removed on * install like the LaunchAgent is: it holds the same singleton flock, so * leaving it would leave two daemons racing for one socket. */ function legacySharedLaunchDaemonPath(): string { return "/Library/LaunchDaemons/ai.failproof.failproofaid.plist"; } /** * The service-definition file `installDaemonService` would write on this * platform (the systemd unit or the launchd plist) — exposed so the config * wizard's review screen can show it alongside every other file it's about * to change, `null` on an unsupported platform. */ export function daemonServiceFilePath(): string | null { if (!isDaemonSupportedPlatform()) return null; return process.platform === "linux" ? systemdUnitPath() : launchdPlistPath(); } /** * The command a user runs to inspect their own daemon — surfaced so the * wizard can print it instead of leaving people to discover which service * manager, and which scope, is involved. */ export function daemonStatusCommand(): string | null { if (!isDaemonSupportedPlatform()) return null; return process.platform === "linux" ? `systemctl status ${systemdUnitName()}` : `sudo launchctl print system/${launchdLabel()}`; } /** * The command that restarts the daemon, for the places where a config change * only takes effect on the next start. * * `--disconnect` is the motivating case: the collector manager starts once for * the daemon's lifetime and the uploader caches its bearer key at construction, * so removing the credential file changes nothing about the process already * running. */ export function daemonRestartCommand(): string | null { if (!isDaemonSupportedPlatform()) return null; return process.platform === "linux" ? `sudo systemctl restart ${systemdUnitName()}` : `sudo launchctl kickstart -k system/${launchdLabel()}`; } /** * Acquires sudo credentials up front, prompting in the terminal if needed. * * `sudo -v` refreshes the user's sudo timestamp and returns; every later * `sudo -n` in this run then succeeds against that cached credential. Doing it * here, once, at a known point in the wizard is what makes the rest of the * install non-interactive — the alternative, an interactive sudo firing from * underneath a half-drawn TUI screen, is a password prompt the user cannot * see. * * NOT the same as running the whole CLI under sudo, which is actively wrong: * `homedir()` would become /root, so the hooks, the policy config, the * downloaded binary and the unit's own `User=` would all be root's rather * than the user's, quietly configuring the wrong account. * * `stdio: "inherit"` so the prompt and the typed password go straight to the * real terminal. The timeout is generous because a human is typing. */ export function primeElevation(): boolean { if (typeof process.getuid === "function" && process.getuid() === 0) return true; if (canElevate()) return true; // NOPASSWD, or already primed try { execFileSync("sudo", ["-v"], { stdio: "inherit", timeout: SUDO_PROMPT_TIMEOUT_MS }); return canElevate(); } catch { // Wrong password, no sudo rights, or the user pressed ctrl-C. All of them // mean the same thing here: carry on without a daemon. return false; } } /** * True when privileged commands can run without prompting for a password. * * Exported because onboarding re-checks it: `needs_root` is the most common * reason setup aborts, and it is the one most likely to stop being true (the * user gets sudo rights, or primes their timestamp in another terminal). One * `sudo -n true`, no prompt, milliseconds — and never on the hook path, which * does not reach the first-run gate at all. */ export function canElevate(): boolean { if (typeof process.getuid === "function" && process.getuid() === 0) return true; try { execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS }); return true; } catch { return false; } } /** * Runs one privileged command, non-interactively. * * `sudo -n` on purpose: the wizard owns the terminal, and a sudo password * prompt fired from underneath a TUI is unreadable at best. A machine that * cannot elevate silently gets a clear reason and the manual commands * instead — it keeps working exactly as it did, on the in-process path. */ export function runPrivileged(command: string, args: string[]): void { const root = typeof process.getuid === "function" && process.getuid() === 0; const [cmd, argv] = root ? [command, args] : ["sudo", ["-n", command, ...args]]; execFileSync(cmd, argv, { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS }); } /** * Installs a file into a root-owned location via a temp file, because the * caller is not root and cannot write there directly. `install -m` sets the * mode in the same step, so the file is never briefly world-writable. */ export function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void { // mkdtempSync, not a name built from pid + timestamp in the shared tmpdir. // That name is guessable, and `writeFileSync` follows a symlink already // sitting at the path — so on a multi-user box another local user could // pre-create it and have `install` copy content they control into // /etc/systemd/system as root. mkdtemp gives a 0700 directory with a name // they cannot predict, so there is nothing to pre-create. const stagingDir = mkdtempSync(resolve(tmpdir(), "failproofaid-stage-")); const staging = resolve(stagingDir, "service-definition"); try { writeFileSync(staging, contents, "utf8"); runPrivileged("install", ["-m", mode, staging, destination]); } finally { rmSync(stagingDir, { recursive: true, force: true }); } } /** * Removes a daemon installed by an earlier version into the user's own * session scope. Best-effort and never privileged — these paths are all * inside the user's home. */ function removeLegacyUserService(): void { try { if (process.platform === "linux") { const legacy = legacySystemdUserUnitPath(); if (!existsSync(legacy)) return; try { execFileSync("systemctl", ["--user", "disable", "--now", "failproofaid.service"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS, }); } catch { // Not loaded — removing the file is still the point. } unlinkSync(legacy); try { execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS, }); } catch { /* best-effort */ } } else { const legacyAgent = legacyLaunchAgentPlistPath(); if (existsSync(legacyAgent)) { try { execFileSync("launchctl", ["unload", "-w", legacyAgent], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS, }); } catch { /* not loaded */ } unlinkSync(legacyAgent); } // The 1.0.0-beta.1 daemon, before the label was per-user. Privileged, // and only reachable on a path that has already elevated. const legacyDaemon = legacySharedLaunchDaemonPath(); if (existsSync(legacyDaemon)) { try { runPrivileged("launchctl", ["unload", "-w", legacyDaemon]); } catch { /* not loaded */ } runPrivileged("rm", ["-f", legacyDaemon]); } } } catch (err) { hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`); } } export function systemdUnitContents( binaryPath: string, workerCmd: string | null, cliCmd: string | null = null, ): string { // Quoted because every value here contains a space or a path — systemd's // Environment= requires quoting whenever the value does. const envLines = serviceEnvironment(workerCmd, cliCmd) .map(([key, value]) => `Environment="${key}=${assertUnitSafe(value, key)}"\n`) .join(""); const user = assertUnitSafe(serviceUser(), "User"); assertUnitSafe(binaryPath, "ExecStart"); assertUnitSafe(homedir(), "HOME"); // Gate the unit on the two files it cannot run without, so that an install // which is no longer there STOPS rather than thrashes. // // `npm rm -g failproofai` is the case this is for, and npm runs no uninstall // script — see the note on `failproofai uninstall`. It deletes the package, // which takes `dist/worker.mjs` with it, while the daemon binary under // ~/.failproofai survives. Without a condition systemd keeps a daemon alive // whose worker cannot spawn; with a deleted BINARY it is worse, because // ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it // trips the start-limit and latches into "start request repeated too // quickly" — a state that then refuses a legitimate restart later. // // A failed condition is not a failure: systemd SKIPS the job, leaves the unit // inactive, and `systemctl status` names the exact path that was missing. // That turns an unexplained crash-loop into a one-line diagnosis, and // `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI // command can clear `daemonConfigured` instead of leaving the machine denying // every tool call. // // This deliberately does NOT soften the hook path's fail-closed deny. A // machine that was configured to require the daemon still denies while the // daemon is absent — being skipped by systemd is not consent to stop // enforcing. It shortens how long that lasts and explains why. // Only paths that EXIST right now are gated on, and that filter is load- // bearing rather than belt-and-braces. // // `binaryPath` is an ExecStart value, not necessarily a bare path: systemd // accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as // "someone named a binary explicitly" — this repo's own systemd tests set it // to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so // gating on that string looks for a file literally named "sleep infinity", // never finds it, and skips a unit that would have run perfectly. Splitting // on whitespace to recover the binary is worse, because a path may legally // contain spaces and there is no way to tell the two apart from here. // // Existence at render time answers it without guessing: a real binary is on // disk when its unit is written (installDaemonService just put it there), and // a command-with-arguments is not. Same rule the worker script already // follows — never gate on a path that is not there, or the freshly installed // unit skips on its very first start. const conditionPaths = [binaryPath, workerScriptPath()].filter( (p): p is string => typeof p === "string" && existsSync(p), ); const conditionLines = conditionPaths .map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`) .join(""); return `[Unit] Description=failproofai background daemon (failproofaid) for ${user} After=network.target ${conditionLines} [Service] Type=simple User=${user} # Set explicitly rather than relying on systemd deriving it from User=: # failproofaid stores per-user state and refuses to start without HOME, so this # required variable is not left to a version-dependent default. Environment="HOME=${homedir()}" ${envLines}ExecStart=${binaryPath} Restart=on-failure RestartSec=2 [Install] # multi-user.target, not default.target: this is the whole point of a # system unit — it starts at boot, with no login and no lingering, and # keeps running after the installing user logs out. WantedBy=multi-user.target `; } function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function unescapeXml(s: string): string { // & last, or an escaped `&lt;` would come back as `<`. return s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); } /** * Exported for the same reason `systemdUnitContents` is: it is the only way to * assert the plist's shape from a Linux CI runner, and this is the half of the * pair no Linux test run can otherwise reach. */ export function launchdPlistContents( binaryPath: string, logDir: string, workerCmd: string | null, cliCmd: string | null = null, ): string { const env = serviceEnvironment(workerCmd, cliCmd); const envBlock = env.length ? ` EnvironmentVariables ${env.map(([key, value]) => ` ${key}\n ${escapeXml(value)}`).join("\n")} ` : ""; return ` Label ${escapeXml(launchdLabel())} ProgramArguments ${escapeXml(binaryPath)} RunAtLoad KeepAlive UserName ${escapeXml(serviceUser())} ${envBlock} StandardOutPath ${escapeXml(resolve(logDir, "failproofaid.log"))} StandardErrorPath ${escapeXml(resolve(logDir, "failproofaid.err.log"))} `; } export interface DaemonInstallResult { installed: boolean; reason?: string; } /** * The privileged commands an install performs — returned verbatim so a * machine that cannot elevate can be told exactly what to run rather than * just that something failed. */ function daemonInstallCommands( binaryPath: string, workerCmd: string | null, cliCmd: string | null, ): string[] { if (process.platform === "linux") { const target = systemBinaryTarget(binaryPath); const dir = systemBinaryDir(); const stage = target ? [ // Quoted: a home directory may hold a space, and an unquoted path // splits into words that stop this `&&`-joined recipe before the // unit is ever written. `sudo install -d -m 0755 ${shellQuote(dir)}`, `sudo install -m 0755 ${shellQuote(binaryPath)} ${shellQuote(target)}`, `{ sudo restorecon -R ${shellQuote(systemBinaryRoot())} 2>/dev/null || true; }`, ] : []; return [ ...stage, `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(target ?? binaryPath, workerCmd, cliCmd)}EOF`, "sudo systemctl daemon-reload", `sudo systemctl enable ${systemdUnitName()}`, // `restart`, not `enable --now`, for the reason the install path gives: // `--now` does nothing to an already-active unit, and these commands are // handed to someone whose daemon may well be running — an upgrade is the // most likely reason they are reading them. `sudo systemctl restart ${systemdUnitName()}`, ]; } return [ `sudo tee ${launchdPlistPath()} < the plist failproofai config would write`, `sudo launchctl load -w ${launchdPlistPath()}`, ]; } /** * Writes and enables the service unit, and leaves the machine running THAT * unit — not merely a unit. * * Safe to call repeatedly: re-running replaces the unit file (picking up a * changed binary path after an upgrade) and restarts the service so the * running process is the one the file describes. It used to say it did that * and did not: `enable --now` is a no-op against an active unit, so every * reinstall over a live daemon rewrote the file and left the old process in * place. See the `restartSystemdUnit()` call below. */ export async function installDaemonService(): Promise { if (!isDaemonSupportedPlatform()) { return { installed: false, reason: `failproofaid is not supported on ${process.platform} yet` }; } // May reach the network when the matching optional platform package is not // installed. The GitHub release asset is the verified fallback channel. const { path: binaryPath, reason: binaryReason } = await ensureFailproofaidBinary(); if (!binaryPath) { return { installed: false, reason: binaryReason ?? "failproofaid binary not found for this platform" }; } // Best-effort, not required: a null workerCmd just leaves the daemon to // its own built-in (relative-path) fallback, which only works when the // daemon happens to be started from the npm package's own directory. // Resolving it here — where FAILPROOFAI_PACKAGE_ROOT is reliably set — // and threading it through as an absolute path in the service's // environment is what makes a *service-managed* daemon actually find its // worker regardless of what cwd the service manager starts it from. const workerCmd = resolveWorkerCommand(); // Same best-effort footing as the worker command, and null is survivable in // the same way — the daemon just has no way to run an audit. Unlike the // worker, there is no relative-path fallback to degrade to, which is exactly // why it is resolved here where FAILPROOFAI_PACKAGE_ROOT is reliably set. const cliCmd = resolveCliCommand(); // The service is installed system-wide, which needs root. Check before // writing anything, so a machine that cannot elevate gets the exact // commands to run instead of a half-installed service. if (!canElevate()) { return { installed: false, reason: "root privileges are required to install the failproofaid system service, and sudo credentials were not available. " + "Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " + "configure root's account instead of yours. To install by hand: " + daemonInstallCommands(binaryPath, workerCmd, cliCmd).join(" && "), }; } // A daemon left over from a pre-1.0.0-beta.1 install holds the same // singleton lock the new one needs, so the system unit would start, lose // the flock race, and leave the machine fail-closed against a daemon that // never came up. Clear it first, every time. removeLegacyUserService(); let execPath = binaryPath; try { if (process.platform === "linux") { // Before the unit is written, so a copy that fails leaves the previous // unit and the daemon it runs untouched. See `systemBinaryDir()`. execPath = stageSystemBinary(binaryPath); writePrivilegedFile(systemdUnitPath(), systemdUnitContents(execPath, workerCmd, cliCmd)); runPrivileged("systemctl", ["daemon-reload"]); // `enable` for boot persistence, then `restart` — NOT `enable --now`. // // `--now` starts a unit that is stopped and does NOTHING to one that is // already active. So on every install over a live daemon it returned // success having changed nothing, and the machine kept running the OLD // binary from the OLD unit — the same trap `ensureDaemonServiceCurrent` // already documents and avoids, which this path never inherited. // // Version skew is where that mattered. The wizard's `daemonBroken` is // `daemonUpToDate && !daemonAnswers`, and `daemonUpToDate` requires // `daemonSkew === null`, so skew can never set it — the // uninstall-then-reinstall path does not fire and this install runs // straight over the still-live old process. `probeDaemon()` then reads a // protocol-mismatch reply from that survivor as `ok`, deliberately, // because it is "acted on elsewhere" — elsewhere being here. The CLI // recorded `daemonConfigured` at the NEW version, and // `pruneOldDaemonBinaries()` was then free to delete the binary the // running process had been started from. The documented recovery for a // `PROTOCOL_VERSION` bump (`npm update -g failproofai` → `failproofai // config`) therefore left the machine exactly as skewed as it began. // // `restart` also covers the fresh-install case: it starts a unit that is // not running, so this is strictly `--now` plus the case `--now` missed. runPrivileged("systemctl", ["enable", systemdUnitName()]); restartSystemdUnit(); } else { const plistPath = launchdPlistPath(); const logDir = logsDir(); mkdirSync(logDir, { recursive: true }); // Unload any previously-loaded copy first — reloading with a changed // binary path (e.g. after an upgrade) is a no-op under plain `load` // if launchd thinks the label is already loaded. try { runPrivileged("launchctl", ["unload", plistPath]); } catch { // Wasn't loaded — fine, this is the common case on a fresh install. } writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd)); runPrivileged("launchctl", ["load", "-w", plistPath]); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); hookLogWarn(`daemon service install failed: ${msg}`); return { installed: false, reason: msg }; } // `enable --now` / `load -w` returning 0 only means the job was accepted. // A daemon that dies at startup (missing shared library, a crash-looping // worker, a binary the service manager can't execute) still gets a clean // exit status here — and the caller would then set `daemonConfigured`, // which makes every hook event on this machine fail closed against a // daemon that isn't there. Confirm it actually reached a running state // before reporting success. if (!(await waitForDaemonRunning())) { const reason = `failproofaid was installed but did not reach a running state within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`; hookLogWarn(`daemon service install failed: ${reason}`); return { installed: false, reason }; } if (execPath !== binaryPath) pruneSystemBinaries(execPath); // NOTE: this reports that the SERVICE is installed and running, which is all // it can honestly claim. Whether the daemon can actually evaluate a hook is a // separate question, answered by `probeDaemonEndToEnd()` — and the caller // must ask it before setting `daemonConfigured`, because that flag is what // makes an unanswering daemon deny every tool call. `configure-wizard.ts` // does exactly that. Keeping the two apart matters: "the unit was written and // systemd started it" is testable against a stub binary, and folding the // probe in here would mean this function could never be tested without a real // failproofaid. return { installed: true }; } /** * How long the health probe waits for the daemon to answer a real hook. * * Far below the hook path's 30s: this runs from an interactive command, and * the cold start it is most likely to be waiting on (`worker.rs` pre-warms the * worker, ~700ms of Node startup) is comfortably inside it. A daemon that * cannot answer a trivial event in this long is not one to hand a machine's * fail-closed enforcement to. */ const DAEMON_PROBE_TIMEOUT_MS = 5_000; /** * How long the probe keeps waiting for the socket to come up before calling the * daemon unreachable. Generous on purpose: this runs once, from an interactive * setup command, and the cost of being impatient is aborting setup at a machine * whose daemon is merely still starting. */ const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000; const DAEMON_PROBE_RETRY_MS = 150; /** * Ask the daemon to evaluate a real hook, end to end. * * The thing this catches that nothing else did: a unit that is *running* while * the worker behind it cannot start. `ExecStart` bakes in `process.execPath` * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service * systemd reports as perfectly active and a worker that dies on every spawn. * Every existing check passes that machine — `waitForDaemonRunning()` asks the * service manager, `Ping` is answered in `server.rs` without ever touching the * worker, a null `resolveWorkerCommand()` is treated as best-effort, and * `Worker::warm()` swallows its own failure. `daemonConfigured` then gets set, * and because that flag makes every hook fail closed, the machine denies every * tool call across all 12 CLIs — including `UserPromptSubmit`, so the user * cannot even talk to their agent to ask why. * * `SessionStart` rather than a tool event: it traverses the identical path * (socket → daemon → worker → `evaluateHookEvent`) but describes nothing a * policy would deny and records no tool decision, so probing cannot itself * change what the machine does. */ export type DaemonProbe = | { ok: true } | { ok: false; /** * `unreachable` — nothing ever accepted a connection on the socket. * `worker` — the daemon accepted a connection but could not answer a * hook, which is the worker failing to run. * * Kept apart because the remedies are different, and because reporting * "your worker will not start" at someone whose worker is fine sends them * to inspect a healthy process. `DaemonFailure` cannot make this * distinction: it reports `unreachable` for a refused connection AND for * a request that was accepted and never answered. */ reason: "unreachable" | "worker"; }; /** * Ask the daemon to evaluate a real hook, end to end. * * The thing this catches that nothing else did: a unit that is *running* while * the worker behind it cannot start. `ExecStart` bakes in `process.execPath` * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service * systemd reports as perfectly active and a worker that dies on every spawn. * * **Why this retries.** It is called moments after `systemctl enable --now`, and * a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before * the daemon has bound its socket. A single attempt therefore raced the bind * and, because the hook path's connect budget is deliberately 150ms, lost that * race on any loaded machine. Setup then aborted with "its worker process could * not be run" at a daemon that was seconds away from serving happily — the * worker had already logged that it was listening. Waiting for the socket is * the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead * daemon from adding latency to every tool call on the hook path. */ export async function probeDaemon(): Promise { try { const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client"); const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS; let everConnected = false; for (;;) { if (await daemonAcceptsConnections()) { everConnected = true; const attempt = await attemptDaemonHook( { hookEvent: "SessionStart", cli: "claude", stdin: JSON.stringify({ hook_event_name: "SessionStart", source: HEALTH_PROBE_SOURCE, }), }, { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS }, ); // A protocol mismatch is a REACHABLE daemon of the wrong vintage, which // is a version problem rather than the lockout this probe exists to // find. It is reported, and acted on, elsewhere. if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true }; } if (Date.now() >= deadline) break; await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS)); } return { ok: false, reason: everConnected ? "worker" : "unreachable" }; } catch { return { ok: false, reason: "unreachable" }; } } /** * "Is anything listening on the daemon socket?" — never throws. * * Split out because `waitForDaemonRunning` needs the connect check WITHOUT the * hook evaluation: it runs inside a settle loop, and a failed import or a * transient refusal there must read as "not yet", never as an error. */ async function daemonAcceptsConnectionsQuietly(): Promise { try { const { daemonAcceptsConnections } = await import("./daemon-client"); return await daemonAcceptsConnections(); } catch { return false; } } /** Boolean form, for callers that only branch on healthy/not. */ export async function probeDaemonEndToEnd(): Promise { return (await probeDaemon()).ok; } /** * Proves that the daemon supports the structured request native plugins use. * * This deliberately follows the ordinary end-to-end hook probe. Besides * retaining its startup-race retry, that makes the failure meaningful: an old * daemon can be fully healthy for shell hooks while still lacking the * `policyEvaluation` variant added for Hermes. Protocol v1 predates that * variant, so version equality alone cannot establish the capability. */ export async function probeDaemonPolicyEvaluation(): Promise { if (!(await probeDaemonEndToEnd())) return false; try { const { attemptDaemonPolicyEvaluation } = await import("./daemon-client"); const attempt = await attemptDaemonPolicyEvaluation( { integration: "hermes", event: "on_session_start", payload: { hook_event_name: "on_session_start", source: "failproofai-native-policy-health-probe", }, }, { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS }, ); return attempt.ok; } catch { return false; } } /** * Waits for the service to report running and to HOLD it — a `Type=simple` unit * is active the moment it forks, so one optimistic reading passes a daemon that * died at startup. * * WATCHED, not slept through. This used to sleep `SERVICE_SETTLE_MS` blind and * read the status once at the end, which was wrong in both directions on a * healthy machine and a broken one: * * • Healthy: the socket is up in ~13ms and answers a real hook in ~125ms, and * setup still sat there for the remaining ~600ms with the answer already in * hand. Setup runs this twice on the repair path (uninstall, reinstall), so * it was over a second of dead wait every time. * • Broken: a daemon that died at 100ms was not noticed until 750ms, because * nothing looked until the sleep was over. * * Now the window is polled. Leaving `running` at any point fails immediately, * and the wait ends early once the daemon has answered a real hook — a reply is * strictly stronger evidence of "did not die at startup" than "still active * after an arbitrary sleep", which is all the settle ever established. */ export interface WaitForDaemonDeps { status?: () => DaemonServiceStatus; accepts?: () => Promise; sleep?: (ms: number) => Promise; now?: () => number; } export async function waitForDaemonRunning(deps: WaitForDaemonDeps = {}): Promise { const status = deps.status ?? daemonServiceStatus; const accepts = deps.accepts ?? daemonAcceptsConnectionsQuietly; const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const now = deps.now ?? Date.now; const deadline = now() + SERVICE_START_TIMEOUT_MS; for (;;) { if (status() === "running") break; if (now() >= deadline) return false; await sleep(SERVICE_START_POLL_MS); } const settleUntil = now() + SERVICE_SETTLE_MS; for (;;) { // A unit that has left `running` is dead now; there is nothing to wait out. if (status() !== "running") return false; // A daemon that accepted a connection is a daemon that got past startup and // bound its socket. Deliberately the CHEAP check, not a full hook // evaluation: `probeDaemon` runs the end-to-end one moments later, and // paying for it twice is what this rewrite exists to stop. if (await accepts()) return true; if (now() >= settleUntil) return true; await sleep(SERVICE_START_POLL_MS); } } // ── Upgrading a service definition that predates a variable ────────────────── // // A machine that installed the daemon before `FAILPROOFAI_CLI_CMD` existed // keeps its old unit forever: `npm i -g failproofai@latest` replaces the CLI // and never touches /etc/systemd/system, and the wizard's own "already // installed and running — leaving it alone" branch skips it too. The daemon // then has no way to spawn an audit while `config.toml` says the scheduled // scan is on, and nothing anywhere reports a fault. This is the single most // likely way the whole feature ends up dead on real machines, so it gets an // explicit detect-and-rewrite rather than an assumption that reinstalls happen. /** Reads the installed unit / plist, or null when no service is installed. */ function readInstalledServiceDefinition(): string | null { if (!isDaemonSupportedPlatform()) return null; const path = daemonServiceFilePath(); if (!path || !existsSync(path)) return null; try { return readFileSync(path, "utf8"); } catch { // Unreadable is not "absent": claiming no service is installed would let a // caller reinstall over something it cannot see. Treated as "nothing to // upgrade" by the one caller below, which is the inert direction. return null; } } /** * Reads one variable back out of an installed service definition. * * Used to carry values FORWARD across a rewrite. A rewrite resolves each * command from this process, and a resolution that comes back null (no * `FAILPROOFAI_PACKAGE_ROOT`, a `dist/` that moved) must never be allowed to * silently DELETE a working line from a working unit — the rewrite exists to * add a variable, not to drop one. */ function installedEnvValue(definition: string, name: string): string | null { if (process.platform === "linux") { const m = new RegExp(`^Environment="${name}=(.*)"$`, "m").exec(definition); return m ? m[1] : null; } const m = new RegExp(`${name}\\s*([^<]*)`).exec(definition); return m ? unescapeXml(m[1]) : null; } /** * The binary an installed definition currently starts. * * Preserved across a rewrite rather than re-resolved, because the two disagree * in exactly the upgrade case this is for: `installedBinaryPath()` is * version-stamped (`failproofaid-`), so right after a CLI upgrade the * binary for the NEW version is not on disk yet and resolution returns null, * while the unit still points at the older binary that is there and running. * Re-resolving would either refuse to rewrite or repoint a live service at a * file that does not exist. */ function installedExecStart(definition: string): string | null { if (process.platform === "linux") { const m = /^ExecStart=(.+)$/m.exec(definition); return m ? m[1].trim() : null; } const m = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec(definition); return m ? unescapeXml(m[1]) : null; } /** * True when a service IS installed but its definition predates * `FAILPROOFAI_CLI_CMD`. * * Deliberately a content check against the definition on disk, not a version * number mirrored into `config.toml`. The mirror is the tempting shape — it is * how `daemon.installed_version` is modelled — but that field is declared, * read and cleared and has never once been WRITTEN (both `setDaemonConfigured` * call sites pass no version), which is precisely how a mirror fails: it * reports "current" for a unit nobody updated. A false negative here is the * permanently-inert audit lane this function exists to catch, and the unit * itself cannot lie about what it contains. */ export function daemonServiceNeedsUpgrade(): boolean { const definition = readInstalledServiceDefinition(); if (definition === null) return false; return !definition.includes("FAILPROOFAI_CLI_CMD"); } /** * The definition an installed one should become: regenerated by the same * renderers a fresh install uses, carrying forward everything this process * cannot re-resolve. `null` when the input has no readable start command, * which means it is not a definition failproofai wrote and must be left alone. * * Split out from `ensureDaemonServiceCurrent` and kept pure — no privilege, no * service manager, no writes — because the rewrite itself can only be * exercised end-to-end on a machine with root AND the matching service * manager. That is nowhere on a Linux CI runner for the launchd half, and * nowhere at all on a developer box without passwordless sudo, so the one * transformation that must not be wrong would otherwise be the one thing no * ordinary test run ever executes. */ export function upgradedServiceDefinition( definition: string, cliCmd: string, logDir: string, ): string | null { const binaryPath = installedExecStart(definition); if (!binaryPath) return null; const workerCmd = resolveWorkerCommand() ?? installedEnvValue(definition, "FAILPROOFAI_WORKER_CMD"); try { return process.platform === "linux" ? systemdUnitContents(binaryPath, workerCmd, cliCmd) : launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd); } catch (err) { // A value that cannot be safely interpolated is reported as "no usable // definition" rather than thrown: this runs against a HEALTHY, RUNNING // daemon and the caller's contract is an outcome, not an exception. The // refusal happens before anything is written or stopped, so the machine is // left exactly as it was. hookLogWarn( `service definition not rewritten: ${err instanceof Error ? err.message : String(err)}`, ); return null; } } export type DaemonUpgradeOutcome = /** No service installed, or its definition already carries the variable. */ | "current" /** The definition was rewritten and the service came back running. */ | "rewritten" /** It needed a rewrite and did not get one. `reason` says why. */ | "failed"; export interface DaemonUpgradeResult { outcome: DaemonUpgradeOutcome; reason?: string; /** * Whether failproofaid is running when this returns — probed, never * inferred from what the commands returned. * * Set on every outcome that touched the service, because on a * `daemonConfigured` machine a stopped daemon is not a lost feature: every * hook event fails closed, across all 12 CLIs, until somebody hand-edits * `policies-config.json`. The caller needs to know that specifically, not * just that "the refresh failed". */ daemonRunning?: boolean; } /** * Restart the systemd unit, clearing any start-limit latch first. * * The unit ships `Restart=on-failure` with `RestartSec=2`, so a definition * systemd accepts but cannot run (a poisoned `User=`, a missing binary) does * not fail once — it cycles, and within `DefaultStartLimitIntervalSec` (10s) it * trips `DefaultStartLimitBurst` (5) and latches into "start request repeated * too quickly". On systemd 255 — what ubuntu-24.04 and GitHub's runners ship — * that latch is sticky at the unit level: a later `systemctl restart` is refused * even after the definition on disk has been REPLACED with a good one. A * rollback that restores a perfectly runnable unit then still cannot start it, * and the machine stays fail-closed against a daemon that never comes back. * `reset-failed` clears the failure counter so the restart is deterministic; on * a healthy unit it is a no-op. Best-effort — a `reset-failed` that errors (the * unit was never failed) must never abort the restart that is the real step. */ function restartSystemdUnit(): void { try { runPrivileged("systemctl", ["reset-failed", systemdUnitName()]); } catch { // The restart below is the operative step; do not let a reset-failed // that errored on an already-clean unit swallow it. } runPrivileged("systemctl", ["restart", systemdUnitName()]); } /** * Puts a previous service definition back and starts the service again, * answering whether failproofaid is running once it has. * * `ensureDaemonServiceCurrent` is the only caller, and it runs exclusively * against a machine whose daemon is UP — which is what makes a half-applied * rewrite so much worse than no rewrite. Stopping a healthy daemon and failing * to start it again trades a dead audit lane for a machine that denies every * tool call, so the refresh must be able to undo itself. * * Best-effort and never throws: it only ever runs where something has already * gone wrong. The return value is `waitForDaemonRunning()` rather than "the * commands succeeded" on purpose — the common rollback failure is a lost sudo * credential, which means nothing was written and the daemon was never down. */ async function restoreServiceDefinition(previous: string): Promise { try { if (process.platform === "linux") { writePrivilegedFile(systemdUnitPath(), previous); runPrivileged("systemctl", ["daemon-reload"]); // reset-failed first: the failed rewrite we are undoing has been cycling // under Restart=on-failure and may have tripped systemd's start-limit, // which on systemd 255 refuses to start even this restored, good unit. restartSystemdUnit(); } else { const plistPath = launchdPlistPath(); try { runPrivileged("launchctl", ["unload", plistPath]); } catch { // Already unloaded — getting the old definition loaded is the point. } writePrivilegedFile(plistPath, previous); runPrivileged("launchctl", ["load", "-w", plistPath]); } } catch (err) { hookLogWarn( `daemon service definition rollback failed: ${err instanceof Error ? err.message : String(err)}`, ); // Not a verdict on the daemon — fall through to the probe below, which is. } return waitForDaemonRunning(); } /** * The one exit for every way a refresh can fail after it started touching the * service. Rolls back when there is something to roll back to, then reports * the daemon's actual state. */ async function refreshFailed(message: string, previous: string | null): Promise { hookLogWarn(`daemon service unit refresh failed: ${message}`); const running = previous === null ? daemonServiceStatus() === "running" : await restoreServiceDefinition(previous); return { outcome: "failed", daemonRunning: running, reason: running ? message : `${message}. The previous service definition could not be restored either, so failproofaid is NOT running`, }; } /** * Brings an already-installed service definition up to date, in place. * * Narrower than `installDaemonService` on purpose, and it is the narrowness * that makes it safe to run against a healthy machine: it never downloads, it * never touches a machine that has no service, and it keeps the ExecStart and * any environment value it cannot re-resolve. Everything past the first * privileged write is undone by `restoreServiceDefinition` if the daemon does * not come back, so the worst outcome really is the machine it started with — * and when even the rollback cannot get the daemon up, that fact rides out on * `daemonRunning` rather than being folded into a generic failure, because the * caller has to stop asserting `daemonConfigured` over a daemon that is gone. * * It DOES restart the service, which `installDaemonService` does not do on * Linux (`enable --now` is a no-op against an already-active unit, so a * rewritten unit's environment reaches the running process only at the next * boot). Without the restart this "fix" would leave the audit lane inert for * however long the machine stays up — which is the bug, not a smaller version * of it. The restart costs a sub-second window in which a hook can find no * daemon and fail closed, so it is confined to the wizard: `failproofai * config` is an explicit, attended reconfiguration, and the same window * already exists on macOS, where install unloads and reloads on every run. */ export async function ensureDaemonServiceCurrent(): Promise { const definition = readInstalledServiceDefinition(); if (definition === null || definition.includes("FAILPROOFAI_CLI_CMD")) { return { outcome: "current" }; } const cliCmd = resolveCliCommand(); if (!cliCmd) { return { outcome: "failed", reason: "the failproofai CLI entry point (dist/cli.mjs) could not be located, so there is nothing to " + "write into the unit. Re-run `failproofai config` from a complete install.", }; } const logDir = logsDir(); const upgraded = upgradedServiceDefinition(definition, cliCmd, logDir); if (!upgraded) { return { outcome: "failed", reason: `no safe replacement could be composed for the service definition at ${daemonServiceFilePath()} — ` + `either it has no readable start command (it looks hand-edited) or a value that would go into it ` + `contains a quote, backslash or newline. It was left alone.`, // Reported because nothing was touched: the refusal happens before any // write and before any stop, so whatever was running still is. The // wizard branches on this to decide whether to clear `daemonConfigured`, // and omitting it here would have read as "the daemon may be down" on a // machine whose daemon is perfectly healthy — which on a // `daemonConfigured` box is the difference between a lost audit lane and // every tool call denied. daemonRunning: daemonServiceStatus() === "running", }; } if (!canElevate()) { return { outcome: "failed", reason: `root privileges are required to rewrite ${daemonServiceFilePath()}, and sudo credentials were not available`, }; } // What to put back if this goes wrong, and from which point. Set BEFORE the // step that can leave the machine changed, not after it succeeds: on Linux // `writePrivilegedFile` is an `install` copy straight onto the unit path, so // a failure part-way through leaves a truncated unit behind, and on macOS // the unload has already stopped the daemon before anything is written. let previous: string | null = null; try { if (process.platform === "linux") { previous = definition; writePrivilegedFile(systemdUnitPath(), upgraded); runPrivileged("systemctl", ["daemon-reload"]); // `restart`, not `enable --now`: the unit is already enabled and active, // so `--now` would return success having changed nothing and the daemon // would keep the environment it was started with. reset-failed first so a // machine that arrived here with an already-latched start-limit (a prior // failed refresh) can still be upgraded. restartSystemdUnit(); } else { const plistPath = launchdPlistPath(); mkdirSync(logDir, { recursive: true }); previous = definition; try { runPrivileged("launchctl", ["unload", plistPath]); } catch { // Not loaded — the rewrite is still the point. } writePrivilegedFile(plistPath, upgraded); runPrivileged("launchctl", ["load", "-w", plistPath]); } } catch (err) { return refreshFailed(err instanceof Error ? err.message : String(err), previous); } // The service was running when this started, so anything short of running // now is a regression this call caused — and on a daemon-configured machine // that is every tool call failing closed. A unit that parses but will not // start is exactly the case `systemctl restart`'s exit code does not catch, // so this is the check that hands it to the rollback. if (!(await waitForDaemonRunning())) { return refreshFailed( `the service definition was refreshed but failproofaid did not come back within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`, previous, ); } return { outcome: "rewritten", daemonRunning: true }; } /** * Stops and removes the service, and clears the `daemonConfigured` marker * so this machine goes back to in-process evaluation. Best-effort: never * throws. * * The flag is cleared **first and unconditionally**: leaving it set with no * daemon to reach is strictly worse than any failure this function can hit, * because `bin/failproofai.mjs` fails closed on it and would deny every hook * event on the machine with no recovery short of hand-editing * `~/.failproofai/policies-config.json`. */ /** * Bring the installed daemon up to this CLI's version — the second half of an * upgrade that npm does not do. * * `npm i -g` replaces the CLI and nothing else. The binary lives at * `~/.failproofai/bin/failproofaid-` precisely so an upgrade cannot swap * it under a running service, which means after an npm upgrade the two halves are * different versions until something like this runs. `daemonVersionSkew()` has * been reporting that state on every command; this is the command that fixes it. * * Composed from the pieces that already exist rather than reimplementing any of * them: `ensureFailproofaidBinary()` puts the right file on disk (npm optional * dependency first, release asset second, SHA-256 verified either way), * `ensureDaemonServiceCurrent()` rewrites the unit if its shape has changed, and * `waitForDaemonRunning()` is what distinguishes "started" from "started and * still up" — a `Type=simple` unit reports active the moment it forks. * * NEVER clears `daemonConfigured`. A refresh that fails leaves a machine * fail-closed against a daemon that may be down, and that is the correct, loud * state: the remedy is `failproofai config`, and silently downgrading the machine * to in-process evaluation would trade a visible failure for an invisible one. * The lines say so. */ export async function refreshDaemonToCliVersion( /** * Injectable for tests, matching `waitForDaemonRunning`'s shape in this file. * A `vi.spyOn` on the module namespace cannot intercept these — the module * calls its own local bindings — so without a seam a test asserting either * branch passes for the wrong reason: on a machine with no service, "no-op" * and "the spy worked" are indistinguishable. */ deps: { status?: () => DaemonServiceStatus; install?: () => Promise; prime?: () => boolean; interactive?: () => boolean; } = {}, ): Promise<{ ok: boolean; lines: string[] }> { const status = deps.status ?? daemonServiceStatus; const install = deps.install ?? installDaemonService; const prime = deps.prime ?? primeElevation; const interactive = deps.interactive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)); const current = status(); if (current === "not-installed") { return { ok: true, lines: ["No failproofaid service on this machine; nothing to update."] }; } // Already this version: nothing to install, so nothing to ask root for. The // reinstall below is idempotent, but it needs sudo, so without this every // `update` after the daemon was brought current either prompted for a password // for no reason or — with no TTY to prompt on (a fleet box, CI) — failed with // "root privileges are required" and exited 1 on a machine that was fine. // `VERSION.daemon` is written only after an install succeeds, and the binary // it names must still be on disk. const recorded = readVersionFile()?.daemon; if (current === "running" && recorded === version && existsSync(installedBinaryPath(version))) { return { ok: true, lines: [`failproofaid ${version} is already installed and running; nothing to update.`] }; } // `installDaemonService()`, not a binary fetch plus a restart. // // The first version of this did the latter, and it did not work: it called // `ensureFailproofaidBinary()` (which correctly lands // `bin/failproofaid-`) and then `ensureDaemonServiceCurrent()`, // whose rewrite goes through `upgradedServiceDefinition` — and that PRESERVES // the existing `ExecStart` via `installedExecStart(definition)`, by design, // because its job is to upgrade the unit's SHAPE without changing which binary // runs. So on a machine coming from an older release the new binary was // downloaded, the unit was rewritten, the service was restarted, and the OLD // binary came back up — under a message that said the daemon had been // refreshed. Worse than not having the command, because it reports success. // // `installDaemonService()` is the function that already knows how to make the // service run THIS CLI's version: it resolves the binary for this version, // writes the unit around that path, removes any legacy user unit, reloads, // enables, and waits for the service to reach AND HOLD a running state rather // than trusting the moment a `Type=simple` unit reports active. It is // idempotent, which is what makes it safe to call on an already-installed // machine. // ASK FOR THE PASSWORD, when there is somebody to ask. // // Writing the unit needs root, and `runPrivileged` uses `sudo -n` — which never // prompts. That rule exists for the WIZARD, whose reason is stated where it is // enforced: a password prompt fired from underneath a full-screen TUI is // unreadable at best. It does not transfer to this command, which is plain line // output with nothing to corrupt, and the wizard itself calls `primeElevation()` // for exactly this at exactly this point. // // Without it, `failproofai update` on the machine it exists for — one with a // daemon, upgrading — failed with "sudo credentials were not available" and a // thirty-line unit file to paste by hand. The only working alternatives were // `sudo -v` first (undocumented) or `failproofai config`, an interactive wizard. // That is not an upgrade path. // // Gated on a TTY, not attempted blindly: on a CI runner or a fleet box there is // nobody to type a password, and `sudo -v` there would block on a prompt nothing // will answer. Those runs still fall through to `sudo -n`, fail, and get the // exact commands to run — which is the right outcome for an unattended machine. // `primeElevation()` is itself a no-op when already root or NOPASSWD. if (interactive()) prime(); const result = await install(); if (!result.installed) { // Root is the usual reason, and the install's own `reason` for it is written // for the wizard and the log: it says to re-run `failproofai config` and // carries a full unit file to paste. From `update` the answer is shorter, // and names this command: get sudo, then run it again. Never "run the CLI // under sudo" — that would configure root's account instead of this one. const needsRoot = /root privileges are required/.test(result.reason ?? ""); return { ok: false, lines: needsRoot ? [ `✕ failproofaid ${version} was not installed: root privileges are required. The previous daemon is untouched.`, " Run `sudo -v` and then `failproofai update` again.", ] : [ `✕ failproofaid ${version} could not be installed: ${(result.reason ?? "unknown reason").replace(/\.$/, "")}. ` + "The previous daemon is untouched.", ` See why with \`${daemonStatusCommand() ?? "systemctl status"}\`.`, ], }; } // RECORD THE VERSION, or the refresh is invisible to everything that asks. // // `installDaemonService()` deliberately does not write it — only the wizard did // (`setDaemonConfigured(true, cliVersion)`), because that is where "this machine // is now configured, at this version" is decided. So without this line the new // binary runs while `VERSION.daemon` still names the old one, and // `daemonVersionSkew()` reads that file: every later CLI command keeps nudging // about a stale daemon that was just replaced, and the wizard's `daemonMaybeUp` // stays false on the skew it should no longer see — so a later `failproofai // config` would tear down and rebuild a perfectly current service. // // `writeVersionFile` rather than `setDaemonConfigured(true, …)`: that helper also // sets `daemon.configured`, and turning on fail-closed enforcement as a SIDE // EFFECT of an update is not this command's decision to make. An update refreshes // what is installed; it does not change whether the machine requires it. writeVersionFile({ daemon: version }); return { ok: true, lines: [`failproofaid ${version} installed, service restarted and holding.`], }; } export async function uninstallDaemonService(): Promise { setDaemonConfigured(false); if (!isDaemonSupportedPlatform()) return; // Always attempted, and never privileged: a legacy user-scope daemon is // the one thing this can still clean up on a machine that cannot elevate. removeLegacyUserService(); try { if (process.platform === "linux") { const unitPath = systemdUnitPath(); if (!existsSync(unitPath)) return; try { runPrivileged("systemctl", ["disable", "--now", systemdUnitName()]); } catch { // Already stopped/not enabled — removing the unit is still the point. } runPrivileged("rm", ["-f", unitPath]); try { runPrivileged("systemctl", ["daemon-reload"]); } catch { // Best-effort. } // The copy systemd ran. After the unit is gone, so nothing enabled is // left pointing at a deleted file. The shared parent goes only if no // other user's directory is in it. const binRoot = systemBinaryRoot(); if (existsSync(binRoot)) { runPrivileged("rm", ["-rf", binRoot]); try { runPrivileged("rmdir", [dirname(binRoot)]); } catch { // Not empty — another user's daemon lives there. } } } else { const plistPath = launchdPlistPath(); if (!existsSync(plistPath)) return; try { runPrivileged("launchctl", ["unload", "-w", plistPath]); } catch { // Already unloaded — fine. } runPrivileged("rm", ["-f", plistPath]); } } catch (err) { // `daemonConfigured` is already cleared above, so a machine that cannot // elevate is back on the in-process path even though the unit file // survives — it fails open, not closed. hookLogWarn( `daemon service uninstall failed (the service may need removing by hand: ${daemonStatusCommand()}): ` + `${err instanceof Error ? err.message : String(err)}`, ); } } /** * Reports the service's actual current state, not just whether the unit * file exists — a unit can be installed but crash-looped into a stopped * state. */ /** * Whether the installed daemon is a different version than this CLI expects. * * The expected version is this CLI's OWN — no lookup, no registry call, no * network. `package.json` and `Cargo.toml` are held identical by CI, and every * downstream path (the download URL, the binary filename, the npm pin) is * derived from that one number, so a CLI can never want a daemon version that * was not published alongside it. * * Returns null when the question does not apply, and the exclusions are the * point: * * • FAILPROOFAI_DAEMON_BINARY — someone named a binary explicitly; its * version is their business, not ours to second-guess. * • a locally-built target/{release,debug} binary — the developer's own * build. Reporting it "stale" on every command would be noise about the * exact setup this repo tells contributors to use. * * Only the managed `~/.failproofai/bin/failproofaid-` path is * checked, because it is the only one whose version we actually own. */ export function daemonVersionSkew(): { installed: string; expected: string } | null { if (process.env.FAILPROOFAI_DAEMON_BINARY) return null; const recorded = readVersionFile()?.daemon; if (!recorded) return null; // Managed installs only — see the exclusions above. if (!existsSync(installedBinaryPath(recorded))) return null; return recorded === version ? null : { installed: recorded, expected: version }; } /** * Why a Linux unit that exists is not active: skipped on a condition, or * merely stopped. * * Only reached once `is-active` has already said "not active", so it costs a * second `systemctl` call on the unhealthy path and nothing at all on the * healthy one. * * `ConditionResult` is systemd's own record of the last condition evaluation * and is `no` only after it actually ran them and one failed. A unit that has * never been started since boot reports `yes` (the field's default), so this * cannot invent a `condition-failed` for a unit systemd has not judged — it * under-reports rather than over-reports, which is the safe direction for a * signal that clears `daemonConfigured`. */ function inactiveLinuxStatus(): DaemonServiceStatus { try { return interpretConditionResult( execFileSync( "systemctl", ["show", systemdUnitName(), "--property=ConditionResult", "--value"], { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS }, ).toString(), ); } catch { // No systemd, no systemctl, or a version without `--value`. The unit file // exists, so "stopped" remains the honest answer — and it is the // conservative one, because nothing acts destructively on it. return "stopped"; } } /** * `systemctl show --property=ConditionResult --value` → a status. * * Split out from the subprocess so the interpretation can be tested without * `/etc/systemd/system`, which this code reads at a fixed path and no test may * write. The live behaviour — that systemd actually skips a unit whose * `ConditionPathExists=` fails, and records `no` when it does — is proven * against a real systemd in the container test rather than asserted here. * * Only a literal `no` means condition-failed. Anything else — `yes`, an empty * string from a unit systemd has not evaluated since boot, an unfamiliar word * from a future version — is "stopped", which is the state nothing acts * destructively on. This under-reports rather than over-reports on purpose: the * cost of a missed `condition-failed` is a flag cleared one command later, and * the cost of a false one is a healthy machine silently dropped to the * in-process path. */ export function interpretConditionResult(raw: string): DaemonServiceStatus { return raw.trim() === "no" ? "condition-failed" : "stopped"; } export function daemonServiceStatus(): DaemonServiceStatus { if (!isDaemonSupportedPlatform()) return "unsupported-platform"; if (process.platform === "linux") { if (!existsSync(systemdUnitPath())) return "not-installed"; try { // Unprivileged on purpose: reading a system unit's state needs no // root, so status works for the owning user with no sudo at all. const out = execFileSync("systemctl", ["is-active", systemdUnitName()], { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS, }) .toString() .trim(); return out === "active" ? "running" : inactiveLinuxStatus(); } catch { // `systemctl is-active` exits non-zero (and execFileSync throws) for // every non-"active" state — inactive, failed, or the command not // working at all (no systemd user session, systemctl missing). The unit // file existing already ruled out "not-installed", so the only question // left is whether systemd skipped it on a condition. return inactiveLinuxStatus(); } } const plistPath = launchdPlistPath(); if (!existsSync(plistPath)) return "not-installed"; try { // A LaunchDaemon lives in launchd's system domain, which an unprivileged // `launchctl list` cannot see — unlike systemd, reading the state needs // the same elevation installing it did. const root = typeof process.getuid === "function" && process.getuid() === 0; // Whether we can elevate AT ALL is checked first, because it decides what a // failure below means. Without it, `sudo -n` refusing for want of a cached // credential was indistinguishable from launchctl reporting the job absent, // and both became "stopped" — so a five-minute-old sudo timestamp made a // running daemon read as dead, and the wizard then demanded a password to // unload and reload a service that was fine. if (!root && !canElevate()) return "unknown"; const args = ["print", `system/${launchdLabel()}`]; const out = root ? execFileSync("launchctl", args, { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS, }).toString() : execFileSync("sudo", ["-n", "launchctl", ...args], { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS, }).toString(); // `state = running` is launchd's own wording; a loaded-but-dead job // prints `state = not running` and must not read as healthy. return /state\s*=\s*running/.test(out) ? "running" : "stopped"; } catch { // We could elevate a moment ago, so this is launchctl's own answer: the // label is not loaded in the system domain. The plist exists (checked // above), so that is genuinely "installed but not running". return "stopped"; } } /** * When the running daemon started, as epoch ms — the source for /settings' * "up 11d" sub-line. Null whenever the answer isn't knowable. * * **From the MONOTONIC stamp, not the printed date.** `ActiveEnterTimestamp` * renders in the host's locale and timezone abbreviation (`Fri 2026-08-14 * 19:45:13 IST`), which `Date.parse` reads as invalid on most abbreviations and, * worse, silently mis-parses on the few it recognises — a settings page * claiming the daemon started three hours in the future is a worse failure than * one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since * boot, locale-free, and pairs with a reading of the SAME clock to give the * epoch time back. * * An EPOCH time rather than a duration, so the page keeps counting without * re-fetching: a duration computed on the server is wrong the moment it renders. * * Linux only for now. launchd exposes no equivalent, so macOS would need the * job's pid out of `launchctl print` and then `ps -o etime=` — a SECOND * privileged call on every settings render, since reading a LaunchDaemon in the * system domain needs elevation. The sub-line is not worth doubling the sudo * traffic of the page; the status itself still renders there. */ export function daemonStartedAtMs(): number | null { if (process.platform !== "linux") return null; if (!existsSync(systemdUnitPath())) return null; try { const raw = execFileSync( "systemctl", ["show", systemdUnitName(), "-p", "ActiveEnterTimestampMonotonic", "--value"], { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS }, ) .toString() .trim(); // `process.hrtime.bigint()`, not `os.uptime()`. See the note on the // function below: the two count suspend differently, and mixing them is // what produced "up 30d" for a daemon started yesterday. return startedAtFromMonotonic(Number(raw), Math.floor(Number(process.hrtime.bigint()) / 1000), Date.now()); } catch { return null; } } /** * The arithmetic behind `daemonStartedAtMs`, split out so it can be tested * without a systemd on the machine running the tests. * * BOTH arguments must be readings of the SAME clock. systemd's * `ActiveEnterTimestampMonotonic` is `CLOCK_MONOTONIC`, which on Linux STOPS * while the machine is suspended; `os.uptime()` reads `/proc/uptime`, which * KEEPS COUNTING through suspend. Subtracting one from the other therefore adds * every second the laptop ever spent asleep to the daemon's apparent age — a * machine that suspends nightly read "up 30d" for a service started yesterday. * The old `< 0` guard caught only the impossible direction; this error is * always positive, so nothing rejected it. * * `process.hrtime.bigint()` is `CLOCK_MONOTONIC` on Linux (libuv's `uv_hrtime`), * the same clock systemd stamped with, so the subtraction is between two points * on one timeline. * * systemd writes 0 for a unit that has never been activated, and a stamp ahead * of the current reading cannot be true — both mean "no answer" rather than a * number, because a wrong uptime is indistinguishable from a right one to the * person reading it. */ export function startedAtFromMonotonic( activeEnterMonotonicUs: number, monotonicNowUs: number, nowMs: number, ): number | null { if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null; if (!Number.isFinite(monotonicNowUs) || monotonicNowUs <= 0) return null; const activeForMs = (monotonicNowUs - activeEnterMonotonicUs) / 1000; if (activeForMs < 0) return null; return Math.round(nowMs - activeForMs); }