import { join } from "node:path"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { getAllManifests } from "agent-relay-providers"; import { gitCheckoutArtifactPaths, prepareGitCheckoutArtifact, rollbackRuntimeSymlink, swapRuntimeSymlink, type GitCheckoutArtifact } from "agent-relay-sdk/git-checkout-artifact"; import { assertRegistryOnlyRuntimeManifest, refreshInPlaceRuntimeAttestation } from "agent-relay-sdk/npm-runtime"; import { errMessage, runInstallWithRetry } from "agent-relay-sdk"; import { shellQuote } from "agent-relay-sdk/shell-utils"; import type { OrchestratorConfig } from "./config"; import { execProcess } from "./process"; import type { RelayClient, RelayCommand } from "./relay"; import { detectSelfSupervision, type SelfSupervision } from "./self-supervision"; import { allocateGuardGeneration, assertNoContendingOwnGuard, assertNoContendingSystemdGuard, atomicWriteFile, buildArtifactRestartGuard, buildGuardPlist, defaultClearMarker, defaultLaunchctl, defaultReadMarker, defaultSystemctl, guardLabelForGenid, GUARD_DISPATCH_TIMEOUT_MS, GUARD_MAX_RUNTIME_SECONDS, launchdLabelProvablyAbsent, ownUpgradeGuardBase, parseSystemdGuardUnits, readLaunchctlGuardSnapshot, resolveRuntimePath, spawnSyncBounded, systemdGuardUnitBase, systemdGuardUnitFor, upgradeGuardPlistPathFor, upgradeGuardResultPathFor, upgradeGuardScriptPathFor, type GuardPidObservationStore, type LaunchctlControl, type MarkerClearer, type MarkerPresence, type MarkerReader, type SystemctlControl, } from "./self-upgrade-guard"; // Re-export the guard identity/script/reaper surface so existing importers (index.ts, tests) // keep a single `./self-upgrade` entry point (#1509 r5 split — see ./self-upgrade-guard.ts). export * from "./self-upgrade-guard"; const VALID_PROVIDERS = new Set(["auto", "all", "orchestrator", ...getAllManifests().map((manifest) => manifest.id)]); const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; /** * #1509 r5 — SINGLE-FLIGHT dispatch flag (closes race class A: same-batch concurrent dispatch). * Module-level so it is process-global; checked-and-set SYNCHRONOUSLY at the top of the artifact * branch with NO await between the read and the write, which makes the check-and-set atomic on * single-threaded JS. Only one self-upgrade restart may be in flight per process: a second * concurrent dispatch throws instead of contending on the shared runtime symlink. Cleared ONLY on * a pre-dispatch failure (so a failed attempt can be retried); NEVER cleared on success — a * successful dispatch is about to tear this process down, so the flag rides the process to its grave. */ let selfUpgradeDispatched = false; /** Test-only: reset the module-level single-flight flag between cases. */ export function __resetSelfUpgradeDispatchedForTest(): void { selfUpgradeDispatched = false; } interface SelfUpgradeOptions { /** Sleep between install retries (injectable for tests). */ sleep?: (ms: number) => Promise; /** Max extra install attempts after the first on a cache-race error. */ installRetries?: number; /** Base backoff (doubles per attempt: base, 2×, 4×, …). */ installRetryBaseMs?: number; /** Override self-supervision detection (injectable for tests). */ supervision?: SelfSupervision; /** * Override `homedir()` for the in-place attestation refresh's git object-store cache * (injectable for tests — `os.homedir()` ignores a runtime `process.env.HOME` change in * Bun, so this is the only way to keep a test off the real machine's home directory). * Also the home used to resolve the git-checkout artifact paths. */ homeDir?: string; /** * Override the sha-resolution clone/fetch/rev-parse timeout (#1457 round 4) so tests * can assert non-blocking behavior against an unresponsive runner without a real wait. */ attestationTimeoutMs?: number; /** * launchd git-checkout only (#1509 r5). Injectable launchctl control for the cross-process * dispatch precondition (enumerate own-unit guards; refuse if one is running). */ launchctl?: LaunchctlControl; /** * systemd git-checkout only (#1509 r8). Injectable systemctl control for the cross-process * dispatch precondition (enumerate own-unit one-shot guard units; refuse if one is active/running). */ systemctl?: SystemctlControl; /** launchd git-checkout only (#1509 r5). Injectable per-generation marker reader (tests). */ readMarker?: MarkerReader; /** launchd git-checkout only (#1509 r8 Finding 2). Injectable stale-marker clearer (tests). */ clearMarker?: MarkerClearer; /** launchd git-checkout only (#1509 r11 Finding 1). Injectable marker-PRESENCE probe (tests) so a * present-but-unreadable marker can be told apart from a provably-absent one. */ markerPresent?: MarkerPresence; /** launchd git-checkout only (#1509 r11 Finding 2). Injectable crash-loop observation store (tests). */ pidObservations?: GuardPidObservationStore; /** Injectable atomic writer for the per-generation script/marker (tests — force a seed failure). */ atomicWrite?: (path: string, content: string, mode?: number) => void; } export interface SelfUpgradeRunner { run(cmd: string[], cwd?: string, options?: { timeoutMs: number; timeoutLabel: string }): Promise<{ exitCode: number; stdout: string; stderr: string }>; commandExists(name: string): boolean; fileExists?(path: string): boolean; } export function localReadinessUrl(apiPort: number | undefined = 4860): string { const port = Number.isInteger(apiPort) && apiPort! > 0 && apiPort! <= 65_535 ? apiPort : 4860; return `http://127.0.0.1:${port}`; } const defaultRunner: SelfUpgradeRunner = { async run(cmd, cwd, options) { const result = await execProcess(cmd, { cwd, ...(options ? { ...options, reapProcessGroup: true } : {}), }); return { exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr }; }, commandExists(name) { // #1509 r11 (Finding 6): bounded like every other synchronous external probe — a hung `which` // must not wedge the event loop; a timeout reports "absent", which callers treat fail-closed. return spawnSyncBounded(["which", name]).exitCode === 0; }, fileExists(path) { return existsSync(path); }, }; /** * launchd git-checkout only (#1509 r5). The generation-INDEPENDENT ingredients planSelfUpgrade * hands to the dispatcher; the per-generation guard (script/marker/result/label, all suffixed * with a fresh genid) is built at DISPATCH time under the single-flight lock — see handleSelfUpgrade * + allocateGuardGeneration — so the monotonic seq++ and uuid identity are allocated exactly once * per actually-dispatched upgrade, never speculatively during planning. */ export interface LaunchdGuardPlan { /** The launchd unit label (`${selfUnit}`). */ unit: string; /** The GUI-domain uid the guard removes/kickstarts under. */ uid: number; /** The runtime symlink path — the shared single-owner resource the single-flight lock protects. */ runtimePath: string; /** `${readinessUrl}` base for the post-restart /api/health probe baked into the guard. */ readinessUrl: string; /** `gui/${uid}/${unit}` — the kickstart domain target. */ service: string; /** `${unit}.upgrade-guard` — the own-vs-foreign scoping key + per-generation label prefix. */ base: string; } /** * systemd git-checkout only (#1509 r8 Finding 1). The generation-INDEPENDENT ingredients for the * one-shot transient-unit guard — the systemd mirror of LaunchdGuardPlan. The per-generation unit * name, script, and result marker are built at DISPATCH under the single-flight lock, so a fresh * uuid identity is minted per real dispatch (never speculatively during planning). */ export interface SystemdGuardPlan { /** The main service unit the guard restarts (`systemctl --user restart `). */ unit: string; /** The runtime symlink path — the shared single-owner resource the single-flight lock protects. */ runtimePath: string; /** `${readinessUrl}` base for the post-restart /api/health probe baked into the guard. */ readinessUrl: string; /** The systemd-safe, own-scoped guard-unit base (`-upgrade-guard`) used for the per-gen unit + glob. */ base: string; /** * Whether `systemd-run` is available. When true the guard is a supervised one-shot transient unit * (Type=oneshot, --collect, RuntimeMaxSec) whose active/inactive state IS the in-progress signal — * this is the full contention invariant. When false we fall back to a detached `setsid` guard (per * generation, single-flight + submitted-fence still apply, but cross-process contention degrades to * same-process single-flight because there is no queryable unit). systemd-run is present on every * systemd host, so the protected Linux orchestrators (wbox, maclinux) always take the supervised path. */ useSystemdRun: boolean; } interface SelfUpgradePlan { targetVersion: string; providers: string[]; unit: string; runtimePrefix?: string; /** npm-reuse only; undefined for git-checkout (that mode attests via `artifact` instead). */ repoUrl?: string; /** * npm-reuse in-place (`runtimePrefix`) only: the package names (no version spec) the * install actually targeted, so the attestation refresh can verify each one truly * landed at `targetVersion` on disk before writing any sha (#1457 round 4). */ runtimePackages?: string[]; installCmd: string[]; /** * The restart command. For systemd non-artifact and launchd non-artifact this is fully built here. * For BOTH artifact paths (launchd and systemd, #1509 r8) it is `[]` — the per-generation one-shot * dispatch command is built at dispatch (see `launchdGuard` / `systemdGuard`). */ restartCmd: string[]; /** restart runs decoupled from this process's cgroup (transient unit) */ restartDetached: boolean; artifact?: GitCheckoutArtifact; /** * launchd git-checkout only (#1509 r5). The generation-independent guard ingredients; the * per-generation script/marker/result/label are built at dispatch under the single-flight lock. */ launchdGuard?: LaunchdGuardPlan; /** * systemd git-checkout only (#1509 r8 Finding 1). The generation-independent one-shot-guard * ingredients; the per-generation unit/script/result are built at dispatch under the single-flight lock. */ systemdGuard?: SystemdGuardPlan; } /** * Build the upgrade plan from a command's params + detected self-supervision. * Throws (with an operator-facing message) when the request is invalid or the * host can't self-restart — caught by the caller and reported as a failed command. */ export function planSelfUpgrade( params: Record, supervision = detectSelfSupervision(), runner: SelfUpgradeRunner = defaultRunner, readinessUrl = localReadinessUrl(), artifactHome: string = homedir(), ): SelfUpgradePlan { const targetVersion = typeof params.targetVersion === "string" ? params.targetVersion.trim() : ""; if (!SEMVER_RE.test(targetVersion)) { throw new Error(`invalid targetVersion "${targetVersion}" (expected x.y.z)`); } const providers = normalizeProviders(params.providers); const mode = params.mode === "git-checkout" ? "git-checkout" : "npm-reuse"; if ((supervision.supervisor !== "systemd" && supervision.supervisor !== "launchd") || !supervision.selfUnit) { throw new Error("orchestrator is not under systemd or launchd; remote self-upgrade requires a managed service"); } const unit = supervision.selfUnit; // #1457: pass through an npm-reuse repoUrl when the dispatcher supplied one, so either // sub-path can attest a gitSha instead of only a version. Optional here (unlike // git-checkout's required repoUrl) — absent, it just keeps today's no-marker behavior. const npmReuseRepoUrl = mode === "git-checkout" ? undefined : optionalRepoUrl(params.repoUrl); const installCmd = mode === "git-checkout" ? [] : buildInstallCommand(targetVersion, providers, supervision, runner, npmReuseRepoUrl); // #1457 round 4: only the in-place `npm install --prefix` case needs a verifiable // package list — its attestation refresh checks each one's on-disk version before // writing a sha. The staged/symlink case attests via prepareNpmRuntimeStaging instead. const npmReusePackages = mode === "npm-reuse" && supervision.runtimePrefix ? packageNamesForProviders(providers) : undefined; const artifact = mode === "git-checkout" ? gitCheckoutArtifactPaths( artifactHome, requiredCommitSha(params.commitSha), requiredRepoUrl(params.repoUrl), ) : undefined; let restartDetached: boolean; let restartCmd: string[]; let launchdGuard: LaunchdGuardPlan | undefined; let systemdGuard: SystemdGuardPlan | undefined; const runtimePath = resolveRuntimePath(supervision.runtimePrefix); if (supervision.supervisor === "launchd") { const uid = process.getuid?.() ?? 501; if (artifact) { // Fix #6: the guard runs as an INDEPENDENT launchd job (the mirror of systemd-run's // out-of-cgroup transient unit) — a backgrounded child would die the instant // `kickstart -k` tears our job down; a bootstrapped launchd job survives to observe the // restart and roll back on failure. #1509 r7 §13: it is a ONE-SHOT job (RunAtLoad, // KeepAlive=false) via `launchctl bootstrap`, NOT the old KeepAlive `launchctl submit`. // // #1509 r5/r7: the per-generation identity (genid = `s${seq}.${uuidhex}`) and everything // suffixed with it — script, one-shot plist, result marker, bootstrap label — are allocated // and built at DISPATCH under the single-flight lock (allocateGuardGeneration + the // handleSelfUpgrade artifact branch), NOT here. planSelfUpgrade only supplies the // generation-independent ingredients so the reviewer-checkable "no two guards share a // path" invariant (§12.1) holds structurally: a fresh uuid is minted per real dispatch. launchdGuard = { unit, uid, runtimePath, readinessUrl, service: `gui/${uid}/${unit}`, base: ownUpgradeGuardBase(unit) }; restartCmd = []; // per-generation one-shot bootstrap command built at dispatch } else { // Shell-background the kickstart so it survives our teardown when launchd kills us. restartCmd = ["/bin/sh", "-c", `launchctl kickstart -kp gui/${uid}/${unit} &`]; } restartDetached = true; } else { // Decouple the restart from this orchestrator's own cgroup: restarting our unit // SIGTERMs us, and a child in our cgroup would be killed mid-restart. systemd-run // schedules it as an independent transient unit that survives our teardown. restartDetached = runner.commandExists("systemd-run"); if (artifact) { // #1509 r8 (Finding 1): the systemd artifact guard is now a per-generation ONE-SHOT transient // unit built at DISPATCH under the single-flight lock — the exact mirror of the launchd // one-shot bootstrap job — NOT a shared `.upgrade-guard.sh`. planSelfUpgrade only supplies the // generation-independent ingredients so no two guards can ever share a script/result/unit path. systemdGuard = { unit, runtimePath, readinessUrl, base: systemdGuardUnitBase(unit), useSystemdRun: restartDetached }; restartCmd = []; // per-generation one-shot dispatch command built at dispatch } else { restartCmd = restartDetached ? ["systemd-run", "--user", "--collect", "--description", "agent-relay orchestrator self-upgrade restart", "systemctl", "--user", "restart", unit] : ["setsid", "systemctl", "--user", "restart", unit]; } } return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, repoUrl: npmReuseRepoUrl, runtimePackages: npmReusePackages, installCmd, restartCmd, restartDetached, artifact, launchdGuard, systemdGuard }; } /** * Handle an orchestrator.upgrade command: validate, install the target version * WITHOUT restart (so install failures are caught while we're still alive), then * launch a decoupled restart. The command is intentionally left "running" — the * relay settles it by reconciling the version we report after we come back up. */ export async function handleSelfUpgrade( command: RelayCommand, config: OrchestratorConfig, relay: RelayClient, runner: SelfUpgradeRunner = defaultRunner, opts: SelfUpgradeOptions = {}, ): Promise { const plan = planSelfUpgrade(command.params, opts.supervision ?? detectSelfSupervision(), runner, localReadinessUrl(config.apiPort), opts.homeDir ?? homedir()); await relay.updateCommand(command.id, "running", { phase: "installing", targetVersion: plan.targetVersion, providers: plan.providers, unit: plan.unit, ...(plan.artifact ? { mode: "git-checkout", commitSha: plan.artifact.sha } : {}), }); if (plan.artifact) { // §8.1 SINGLE-FLIGHT GATE (§3, closes race class A). Synchronous check-and-set with NO await // between — atomic on single-threaded JS. A second concurrent dispatch throws here and NEVER // reaches the try below, so it can neither swap the shared symlink nor clear the winner's flag. if (selfUpgradeDispatched) throw new Error("a self-upgrade restart is already pending"); selfUpgradeDispatched = true; const runtimePath = plan.runtimePrefix ?? resolveRuntimePath(); const launchctl = opts.launchctl ?? defaultLaunchctl; const systemctl = opts.systemctl ?? defaultSystemctl; const readMarker = opts.readMarker ?? defaultReadMarker; const clearMarker = opts.clearMarker ?? defaultClearMarker; const atomicWrite = opts.atomicWrite ?? atomicWriteFile; // §8.2 SYNC cross-process precondition, both platforms (#1509 r5 launchd; r8 Finding 1 systemd). // Refuse (fail closed) if any own-unit guard could still contend on the shared runtime symlink — // a RUNNING launchd one-shot guard / systemd active guard unit, or an unreadable snapshot. On // launchd it ALSO self-heals a stale pre-r7 `active` marker so it can never wedge (r8 Finding 2). // Cheap; called again immediately before the swap (defect 2d) so a guard that became live during // the awaited prepare cannot slip into contention (no await between the recheck and the swap). const assertNoContention = (): void => { if (plan.launchdGuard) { assertNoContendingOwnGuard(plan.launchdGuard.base, runtimePath, launchctl, readMarker, clearMarker, opts.markerPresent, opts.pidObservations); } else if (plan.systemdGuard && plan.systemdGuard.useSystemdRun) { // Only the supervised (systemd-run) path has a queryable unit to gate on; the setsid fallback // relies on same-process single-flight alone (see SystemdGuardPlan.useSystemdRun). The detector // is cross-scheme (#1509 r9 Finding 1): it blocks a RUNNING prior-generation auto-named // `run-*.service` guard too, so the r7→r8/r9 transition cannot admit a second dispatch. assertNoContendingSystemdGuard(plan.systemdGuard.base, runtimePath, systemctl); } }; let swap: ReturnType | undefined; // §8 point-of-no-return fence (#1509 r6 defect 1; MOVED BEFORE the dispatch await, r11 Finding 5). // Set true the instant the one-shot guard dispatch command is ISSUED (launchctl bootstrap / // systemd-run) — not when its CLI reports success: the manager registers and starts the guard // BEFORE the CLI returns, so a hang/timeout/ambiguous failure of the CLI can leave the guard // independently live. Once (maybe-)submitted, the guard is the running safety net — a failure // must NOT roll back the shared symlink and must NOT clear the dispatch flag: doing so would let // a live guard operate on a parent-mutated runtime and re-permit a second dispatch that races it. // The fence is lowered again ONLY on POSITIVE observation that the exact per-generation // label/unit is absent from a fresh, readable manager listing (provably never registered). // Pre-dispatch failures (precondition/prepare/swap/script/plist) roll back + clear as before. let submitted = false; try { // §8.2 fail-fast precondition BEFORE the awaited prepare. assertNoContention(); // §8.3 prepare the pinned checkout. await prepareGitCheckoutArtifact(plan.artifact, runner.run.bind(runner)); // §8.2b RE-EVALUATE the precondition IMMEDIATELY before the swap (#1509 r6 defect 2d). The // await above is a TOCTOU window: a guard could start running into contention while we prepared. // There is NO await between this recheck and the swap below, so on single-threaded JS the // check-and-swap is atomic — a guard that became live blocks here. assertNoContention(); // §8.4 swap the runtime symlink to the new checkout (rolled back on any PRE-submit failure). swap = swapRuntimeSymlink(runtimePath, plan.artifact.checkoutDir); // §8.5 allocate a fresh generation (atomic seq++, fail-closed) UNDER the single-flight lock — // shared by both platforms so no two guards ever share a script/result/unit/label path (§12.1). const gen = allocateGuardGeneration(runtimePath); const scriptPath = upgradeGuardScriptPathFor(runtimePath, gen.genid); const resultPath = upgradeGuardResultPathFor(runtimePath, gen.genid); let dispatchLabel: string; if (plan.launchdGuard) { const lg = plan.launchdGuard; const plistPath = upgradeGuardPlistPathFor(runtimePath, gen.genid); const label = guardLabelForGenid(lg.base, gen.genid); const domain = `gui/${lg.uid}`; const domainTarget = `${domain}/${label}`; dispatchLabel = label; // #1509 r7 §13: the guard is a ONE-SHOT bootstrapped job — NO status/completion marker is // written (its presence-and-running state IS the in-progress signal). Only the script + // one-shot plist are written. const script = buildArtifactRestartGuard("launchd", lg.unit, runtimePath, lg.readinessUrl, lg.service, label, domainTarget, resultPath, plistPath); // §8.6 write the per-generation guard script atomically (temp + fsync + rename), 0o755. atomicWrite(scriptPath, script, 0o755); // §8.7 write the per-generation one-shot plist atomically (RunAtLoad, KeepAlive=false). A // write failure throws → the outer catch rolls the symlink back and clears the flag. atomicWrite(plistPath, buildGuardPlist(label, scriptPath)); // §8.8 bootstrap the one-shot guard under its exact per-generation label (bounded — r8). const submitCmd = ["/bin/sh", "-c", `launchctl bootout ${shellQuote(domainTarget)} 2>/dev/null; launchctl bootstrap ${shellQuote(domain)} ${shellQuote(plistPath)}`]; // #1509 r11 (Finding 5): raise the fence BEFORE awaiting — `launchctl bootstrap` registers and // RunAtLoad-starts the guard before its CLI returns, so a hung/timed-out/ambiguously-failed // CLI can leave the guard independently live. Lower the fence again ONLY when a fresh readable // listing PROVES the exact label was never registered (then the clean rollback is safe). submitted = true; let restart: { exitCode: number; stdout: string; stderr: string }; try { restart = await runner.run(submitCmd, undefined, { timeoutMs: GUARD_DISPATCH_TIMEOUT_MS, timeoutLabel: "launchctl bootstrap self-upgrade guard" }); } catch (error) { if (launchdGuardProvablyAbsent(launchctl, lg.unit, label)) submitted = false; throw error; } if (restart.exitCode !== 0) { if (launchdGuardProvablyAbsent(launchctl, lg.unit, label)) submitted = false; throw new Error(`restart failed (exit ${restart.exitCode}): ${(restart.stderr || restart.stdout).trim().slice(-500)}`); } } else { // §8 systemd artifact path (#1509 r8 Finding 1) — a per-generation ONE-SHOT transient unit, // the exact mirror of the launchd bootstrap job. NO status/completion marker (its unit // active/inactive state IS the in-progress signal); `--collect` GCs the unit on exit so a // finished guard's unit simply disappears (terminal ⇒ never blocks). RuntimeMaxSec is // systemd's hard backstop on the whole guard's runtime (Finding 3). const sg = plan.systemdGuard!; const guardUnit = systemdGuardUnitFor(sg.base, gen.genid); dispatchLabel = guardUnit; const script = buildArtifactRestartGuard("systemd", sg.unit, runtimePath, sg.readinessUrl, undefined, undefined, undefined, resultPath); // §8.6 write the per-generation guard script atomically (temp + fsync + rename), 0o755. atomicWrite(scriptPath, script, 0o755); // #1509 r9 (Finding 3): `--no-block` makes `systemd-run` return the instant the transient unit // is REGISTERED, not when ExecStart exits — the pre-r9 omission meant that for Type=oneshot the // start job (and thus `systemd-run`) only completed once the guard EXITED, so a slow/hung guard // stalled the caller until its 15s dispatch timeout and left `submitted` ambiguous. Now: // registered ⇒ unit is activating ⇒ the active-state gate (Finding 1) blocks the next dispatch, // and registration success IS the `submitted` point-of-no-return. The runtime is bounded by BOTH // `TimeoutStartSec` (the authoritative cap on a Type=oneshot ExecStart — `RuntimeMaxSec` does not // reliably bound oneshot STARTUP) and `RuntimeMaxSec` as a backstop; systemd SIGTERM/SIGKILLs a // guard that overruns either, so the unit goes failed ⇒ `--collect`-GC'd ⇒ terminal ⇒ unblocks. const restartCmd = sg.useSystemdRun ? ["systemd-run", "--user", "--collect", "--no-block", "--unit", guardUnit, "--property=Type=oneshot", `--property=TimeoutStartSec=${GUARD_MAX_RUNTIME_SECONDS}`, `--property=RuntimeMaxSec=${GUARD_MAX_RUNTIME_SECONDS}`, "--description", "agent-relay git-checkout self-upgrade restart guard", "/bin/sh", scriptPath] : ["setsid", "/bin/sh", scriptPath]; // #1509 r11 (Finding 5), mirroring launchd: `systemd-run` may REGISTER the unit before its // CLI returns/fails, so the fence rises before the await and is lowered only when a fresh // readable listing proves the exact per-generation unit absent. The setsid fallback has no // queryable unit, so there a failure can never be proven clean — the fence stays up (fail // closed; the path is unreachable on real systemd hosts). submitted = true; let restart: { exitCode: number; stdout: string; stderr: string }; try { restart = await runner.run(restartCmd, undefined, { timeoutMs: GUARD_DISPATCH_TIMEOUT_MS, timeoutLabel: "systemd-run self-upgrade guard" }); } catch (error) { if (sg.useSystemdRun && systemdGuardUnitProvablyAbsent(systemctl, sg.base, guardUnit)) submitted = false; throw error; } if (restart.exitCode !== 0) { if (sg.useSystemdRun && systemdGuardUnitProvablyAbsent(systemctl, sg.base, guardUnit)) submitted = false; throw new Error(`restart failed (exit ${restart.exitCode}): ${(restart.stderr || restart.stdout).trim().slice(-500)}`); } } // POINT OF NO RETURN (#1509 r6 defect 1; raised pre-await since r11 Finding 5): the one-shot // guard is dispatched and running. Any failure from here on (the restart-pending PATCH below) // must leave the guard + symlink in place. // §8.9 announce restart-pending with THIS generation's result marker path. await relay.updateCommand(command.id, "running", { phase: "restart-pending", targetVersion: plan.targetVersion, unit: plan.unit, restartDetached: plan.restartDetached, mode: "git-checkout", commitSha: plan.artifact.sha, guardResultPath: resultPath, }); console.error(`[orchestrator] git-checkout self-upgrade to ${plan.artifact.sha} prepared; restart dispatched for ${plan.unit} (${dispatchLabel})`); } catch (error) { if (submitted) { // §8 POINT-OF-NO-RETURN passed (#1509 r6 defect 1; r11 Finding 5). The restart is dispatched // — or its dispatch command MAY have taken effect and could not be proven otherwise — and // the guard is (possibly) the running safety net. Such a failure — the restart-pending PATCH // rejecting because Relay disconnected, a bootstrap CLI that hung/failed after the manager // already registered the guard — must NOT roll back the shared symlink and must NOT clear // the dispatch fence: the guard verifies/rolls back on its own, and clearing the flag would // re-permit a second dispatch that races it. Log and keep both in place. console.error(`[orchestrator] self-upgrade step failed after the restart guard was (possibly) dispatched for ${plan.unit}; leaving the symlink swapped and dispatch fenced: ${errMessage(error)}`); return; } // PRE-submit failure (precondition / prepare / swap / script or plist write, or a dispatch // command PROVEN never to have registered the guard): roll the symlink back and clear the // in-process flag so a subsequent upgrade can be attempted. if (swap) rollbackRuntimeSymlink(swap); selfUpgradeDispatched = false; throw error; } return; } // Option-4 fail-fast guard (#1435). The npm-reuse RPC install is in-place and additive — // it must not drop the relay server co-installed in the same prefix — so it stays an // `npm install --prefix ` rather than a staging+promote (that hardening is // the CLI's job, and the RPC would need the same complete-managed-set + crash-safe swap the // CLI has). Crucially, this prefix is NEVER the symlinked source checkout that triggers // #1435: detectRuntimePrefix() derives it from THIS process's own module path (the segment // before `/node_modules/`), so on a git-checkout host — where the orchestrator runs from // source with no `/node_modules/` ancestor — it is undefined and buildInstallCommand() // delegates to the `agent-relay upgrade` CLI (which installs into a fresh staging prefix). // The prefix is set ONLY on clean-npm hosts, where it is a real dir with a registry-only // manifest. This guard is the SAFE FLOOR: if a symlink-to-source prefix ever did reach here, // refuse it with a clear, actionable error instead of npm's opaque EUNSUPPORTEDPROTOCOL. if (plan.runtimePrefix) assertRegistryOnlyRuntimeManifest(plan.runtimePrefix); const install = await runInstallWithRetry(plan.installCmd, runner.run.bind(runner), { sleep: opts.sleep, retries: opts.installRetries, baseDelayMs: opts.installRetryBaseMs, onRetry: async ({ attempt, delayMs }) => { await relay.updateCommand(command.id, "running", { phase: "installing", targetVersion: plan.targetVersion, unit: plan.unit, retry: attempt, retryDelayMs: delayMs, note: `target ${plan.targetVersion} not yet visible to this host's npm cache; retrying (attempt ${attempt})`, }); console.error(`[orchestrator] self-upgrade install hit a stale-cache race for ${plan.targetVersion}; retry ${attempt} in ${delayMs}ms`); }, }); if (install.exitCode !== 0) { throw new Error(`install failed (exit ${install.exitCode}): ${(install.stderr || install.stdout).trim().slice(-500)}`); } if (plan.runtimePrefix) { // #1457 round 3: this in-place `npm install --prefix` is the ORDINARY npm-reuse case // (a host that never hit #1435) — unlike the staged/symlink path, nothing else here // would otherwise write or refresh the attestation marker. Best-effort: never let a // resolution failure fail an upgrade that already succeeded, and (round 4) never let // it BLOCK the restart below — refreshInPlaceRuntimeAttestation self-bounds with a // timeout and only writes a sha once the on-disk package versions are verified. await refreshInPlaceRuntimeAttestation( opts.homeDir ?? homedir(), plan.runtimePrefix, plan.repoUrl, plan.targetVersion, plan.runtimePackages ?? [], runner.run.bind(runner), opts.attestationTimeoutMs, ); } await relay.updateCommand(command.id, "running", { phase: "restart-pending", targetVersion: plan.targetVersion, unit: plan.unit, restartDetached: plan.restartDetached, }); // Fire the restart and return. We do not await its effect — it tears us down. const restart = await runner.run(plan.restartCmd); if (restart.exitCode !== 0) { throw new Error(`restart failed (exit ${restart.exitCode}): ${(restart.stderr || restart.stdout).trim().slice(-500)}`); } console.error(`[orchestrator] self-upgrade to ${plan.targetVersion} installed; restart dispatched for ${plan.unit}`); } /** * #1509 r11 (Finding 5): after a failed/hung `launchctl bootstrap`, ONLY a fresh READABLE listing * that lacks the exact per-generation label proves the guard was never registered (⇒ the clean * rollback is safe). An unreadable listing, or the label present in any state, keeps the * maybe-submitted fence up — we never roll back what we cannot prove dead. * * #1509 r12 (Finding 3): "readable" now also means WELL-FORMED — readLaunchctlGuardSnapshot returns * null for exit-0-but-garbled output, which pre-r12 parsed to [] and read as proof of absence, * lowering the fence (rollback + re-opened dispatch) beside a live-but-invisible guard. * * #1509 r13 (Finding 2): …and COMPLETE — the snapshot must contain this orchestrator's own live * job (`selfUnit`, the completeness anchor); a truncated/wrong-domain listing whose lines are all * individually valid is still not proof the guard label is absent. * * #1509 r14 (Finding 4): the label is KNOWN, so absence is proven by the DIRECT per-label probe * (control-first — see launchdLabelProvablyAbsent), which no clean-row-boundary list truncation * can fool; the anchored-list scan remains only as the fallback for controls without the probe. */ function launchdGuardProvablyAbsent(launchctl: LaunchctlControl, selfUnit: string, label: string): boolean { const probed = launchdLabelProvablyAbsent(launchctl, selfUnit, label); if (probed !== null) return probed; const jobs = readLaunchctlGuardSnapshot(launchctl, selfUnit); return jobs !== null && !jobs.some((job) => job.label === label); } /** The systemd mirror of launchdGuardProvablyAbsent (#1509 r11 Finding 5) — exact per-gen unit. * #1509 r12 (Finding 3): a malformed listing (parse ⇒ null) is NOT proof of absence either. * #1509 r14 (Finding 4): the unit is KNOWN, so prefer the direct per-unit LoadState probe — * systemd's `not-found` is a POSITIVE statement of absence, immune to list truncation; anything * else (loaded, garble, failure) is not proof. List fallback only for controls without the probe. */ function systemdGuardUnitProvablyAbsent(systemctl: SystemctlControl, base: string, guardUnit: string): boolean { const probed = systemctl.probeUnitLoadState?.(`${guardUnit}.service`); if (probed !== undefined) { return probed.exitCode === 0 && probed.stdout.trim() === "not-found"; } const confirm = systemctl.listGuardUnits(base); if (confirm.exitCode !== 0) return false; const units = parseSystemdGuardUnits(confirm.stdout); return units !== null && !units.some((unit) => unit.name === `${guardUnit}.service`); } function requiredCommitSha(value: unknown): string { if (typeof value !== "string" || !/^[0-9a-f]{40}$/i.test(value.trim())) throw new Error("git-checkout mode requires a 40-character commitSha"); return value.trim(); } function requiredRepoUrl(value: unknown): string { if (typeof value !== "string" || !value.trim()) throw new Error("git-checkout mode requires repoUrl"); return value.trim(); } /** Unlike git-checkout's requiredRepoUrl, npm-reuse attestation is best-effort (#1457). */ function optionalRepoUrl(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function normalizeProviders(value: unknown): string[] { const list = Array.isArray(value) ? value.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean) : []; const filtered = list.filter((p) => VALID_PROVIDERS.has(p)); const providers = filtered.length ? filtered : ["orchestrator"]; return [...new Set(providers)]; } function buildInstallCommand( targetVersion: string, providers: string[], supervision: SelfSupervision, runner: SelfUpgradeRunner, repoUrl?: string, ): string[] { if (supervision.runtimePrefix) { if (!runner.commandExists("npm")) { throw new Error("npm is required for runtime-prefix self-upgrade"); } // A real-directory (clean-npm) runtime never staged via prepareNpmRuntimeStaging and // has no gitSha marker mechanism to feed either (out of scope; see sdk/src/npm-runtime.ts). return [ "npm", "install", // Revalidate the packument instead of trusting a stale cache, so a // just-published version is visible to this host's npm right away (#211). "--prefer-online", "--prefix", supervision.runtimePrefix, ...packagesForProviders(targetVersion, providers), ]; } const runtimeCli = join(homedir(), ".agent-relay", "runtime", "node_modules", ".bin", "agent-relay"); if (runner.fileExists?.(runtimeCli)) { return [ runtimeCli, "upgrade", "--version", targetVersion, "--providers", providers.join(","), // #1457: lets the spawned CLI (a symlink-runtime host) resolve + attest a real // gitSha for targetVersion instead of relying on the target's own ambient // AGENT_RELAY_ARTIFACT_REPO, which the default remote upgrade dispatch never sets. ...(repoUrl ? ["--repo-url", repoUrl] : []), "--no-restart", "--yes", ]; } throw new Error(`runtime agent-relay CLI is not available at ${runtimeCli} and no runtime prefix was detected; refusing ambient PATH self-upgrade`); } /** Bare package names (no version spec) an upgrade targets for the given providers. */ function packageNamesForProviders(providers: string[]): string[] { const selected = new Set(providers); const packages = new Set(); const includeAll = selected.has("all"); const includeAuto = selected.has("auto"); if (includeAll || includeAuto || selected.has("orchestrator")) { packages.add("agent-relay-orchestrator"); } for (const manifest of getAllManifests()) { if (!manifest.upgrade?.packages?.length) continue; if (!includeAll && !selected.has(manifest.id)) continue; for (const providerPackage of manifest.upgrade.packages) packages.add(providerPackage); } if (packages.size === 0) packages.add("agent-relay-orchestrator"); return [...packages]; } function packagesForProviders(targetVersion: string, providers: string[]): string[] { return packageNamesForProviders(providers).map((pkg) => `${pkg}@${targetVersion}`); }