import { readFileSync, realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSyncBounded } from "./self-upgrade-guard"; export interface SelfSupervision { supervisor: "process" | "systemd" | "launchd" | "unknown"; selfUnit?: string; runtimePrefix?: string; } let cached: SelfSupervision | undefined; /** * Detect how this orchestrator process is supervised so the relay can target a * remote self-upgrade at the correct unit/label and install prefix. Result is * stable for the process lifetime, so it is computed once and cached. */ export function detectSelfSupervision(moduleUrl: string = import.meta.url): SelfSupervision { if (cached) return cached; cached = { supervisor: detectSupervisorRaw(), runtimePrefix: detectRuntimePrefix(moduleUrl) }; const unit = detectSystemdUnit(); if (unit) { cached.supervisor = "systemd"; cached.selfUnit = unit; } else if (process.platform === "darwin") { const label = detectLaunchdLabel(); if (label) { cached.supervisor = "launchd"; cached.selfUnit = label; } } return cached; } /** Reset the cache. Test-only. */ function resetSelfSupervisionCache(): void { cached = undefined; } function detectSupervisorRaw(): SelfSupervision["supervisor"] { // /proc only exists on Linux; on macOS (launchd) we can't introspect cheaply. try { readFileSync("/proc/self/cgroup", "utf8"); return "process"; } catch { return "unknown"; } } /** * Parse the systemd unit owning this process from /proc/self/cgroup. A * --user service cgroup looks like: * 0::/user.slice/user-1000.slice/user@1000.service/app.slice/agent-relay-orchestrator.service * We want the LAST `*.service` segment that isn't the user manager itself. */ export function parseSystemdUnitFromCgroup(cgroup: string): string | undefined { const services = cgroup .split("\n") .flatMap((line) => line.split("/")) .filter((seg) => seg.endsWith(".service")) .filter((seg) => !/^user@\d+\.service$/.test(seg) && seg !== "init.scope"); const last = services.at(-1); return last && last.length > ".service".length ? last : undefined; } function detectSystemdUnit(): string | undefined { try { return parseSystemdUnitFromCgroup(readFileSync("/proc/self/cgroup", "utf8")); } catch { return undefined; } } /** * Parse `launchctl list` output to find the label for a given PID. Output format * is tab-separated: PID\tStatus\tLabel (PID is "-" when not running). */ export function parseLaunchdLabelForPid(output: string, pid: number): string | undefined { const target = String(pid); for (const line of output.split("\n")) { const parts = line.split("\t"); if (parts[0] === target && parts[2]) return parts[2]; } return undefined; } function detectLaunchdLabel(): string | undefined { // #1509 r11 (Finding 6): hard-bounded (10s, SIGKILL) — this runs during process-lifetime // supervision detection, so an unbounded hung `launchctl` would wedge launchd orchestrator // STARTUP itself. A timeout reports exit 1 ⇒ undefined ⇒ "not launchd-supervised" (fail closed: // no self-upgrade is attempted rather than a blind one). const result = spawnSyncBounded(["launchctl", "list"]); if (result.exitCode !== 0) return undefined; return parseLaunchdLabelForPid(result.stdout, process.pid); } /** * The install prefix is the directory above `node_modules` when the orchestrator * runs from an installed package (e.g. ~/.agent-relay/runtime). Undefined when * running from a source/workspace checkout. */ export function detectRuntimePrefix(moduleUrl: string): string | undefined { let path: string; try { path = fileURLToPath(moduleUrl); } catch { return undefined; } const marker = "/node_modules/"; const idx = path.indexOf(marker); return idx >= 0 ? path.slice(0, idx) : undefined; } /** * Resolve the artifact sha of the code THIS process is running, by walking the * module's own directory ancestry for the `.agent-relay-artifact-sha` marker * (it sits at a git-checkout artifact root; npm installs have none → undefined). * * The module path is realpath-resolved first, so a process launched through the * `~/.agent-relay/runtime` symlink attests the physical checkout it loaded from * — never whatever the symlink points at later (#1316: a symlink swap without a * restart must not change what an old process reports). */ export function resolveProcessArtifactSha(moduleUrl: string = import.meta.url): string | undefined { let path: string; try { path = fileURLToPath(moduleUrl); } catch { return undefined; } try { path = realpathSync(path); } catch { // keep the unresolved path; the walk below still works for real directories } let dir = dirname(path); while (true) { try { const sha = readFileSync(join(dir, ".agent-relay-artifact-sha"), "utf8").trim(); if (/^[0-9a-f]{40}$/i.test(sha)) return sha; } catch { // no marker at this level — keep walking } const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } /** * Frozen at process startup (module load). This is the value to report in * registration meta and every heartbeat — it attests the running process, not * a file re-read through the mutable runtime symlink. */ export const PROCESS_ARTIFACT_SHA: string | undefined = resolveProcessArtifactSha();