/** * `aicommander-agent self-update` — the upgrade procedure as CODE, because as * prose it is reliably got wrong. * * The documented Linux upgrade is not hard, it is just unforgiving, and every * one of its traps is invisible until it fires. All three of these happened in * one sitting on 2026-08-10, to a caller that had the README open: * * - `sh install` instead of `bash install` — Ubuntu's /bin/sh is dash, which * has no `set -o pipefail`, so the installer dies on line 2; * - `curl /install | bash` — that path is a MUTABLE TEMPLATE which refuses to * run (its version placeholder is unsubstituted); the real one is the signed * `/dist/v//install`, and the difference is invisible until you read the * error; * - running any of it in the FOREGROUND of a remote_exec — the installer * restarts the service, which kills the agent, which kills the command that * was mid-install. * * And the subtlest one: a health check that asks "is the agent running?" answers * YES after a failed upgrade, because the OLD agent is running perfectly well. * The only question worth asking afterwards is "is it running the version I * asked for?". * * So this command does the whole thing itself: detaches from its caller, fetches * the versioned installer, verifies it against a signing key COMPILED IN (not * fetched — a key you download from the same host as the payload proves nothing), * backs up the current binary, installs, then verifies the VERSION and that the * service stayed up — and restores the backup if either fails. * * Scope is deliberately narrow. It refuses anything it cannot make safe rather * than improvising: see `selfUpdatePreflight`. */ /** The value README publishes, and the one an operator confirms out of band. */ export declare const RELEASE_SIGNING_KEY_SHA256 = "2d76d381fc8ed38e7dfb53882e14b2980ee105e0b49ff31cf55403e19e648407"; /** * How the worker is launched so the upgrade cannot kill its own supervision. * * `detached: true` is NOT enough, and the difference is invisible until it costs * you: it starts a new session and process group, but the child stays in the * agent service's CGROUP — and the unit runs with systemd's default * `KillMode=control-group`, so `systemctl restart aicommander-agent` (which the * installer performs) kills every process in that group. The updater would be * killed by the restart it just triggered, half-way through, with the binary * already swapped and nothing left to verify or roll back. * * This is not a theoretical concern — it happened on 2026-08-10, to a hand-written * setsid script whose log simply stops after "backup ok". The upgrade landed by * luck; the verification and rollback never ran and nobody noticed until the cgroup * was checked. * * `systemd-run` puts the worker in a transient unit of ITS OWN, outside the agent's * control group, which is the whole point. It is also why preflight insists on * systemd: without it there is no way to escape, and the honest answer is to refuse. */ export declare function selfUpdateLaunchArgv(binary: string, forwardedArgs: readonly string[], env?: Readonly>): string[]; export interface PreflightEnv { platform: NodeJS.Platform; /** process.getuid(), or undefined on a platform without one. */ uid: number | undefined; /** Basename of process.execPath — the agent binary, or a JS runtime. */ execName: string; /** Whether this machine is running systemd (i.e. /run/systemd/system exists). */ hasSystemd: boolean; } /** * Why this machine may not self-update, or null when it may. * * Each refusal names the supported alternative, because "no" without a next step * is what pushes a caller back into improvising — which is the thing this whole * command exists to stop. */ export declare function selfUpdatePreflight(env: PreflightEnv): string | null; /** Fingerprint of the compiled-in key, in the form README publishes. */ export declare function signingKeyFingerprint(): string; /** * Is `installer` genuinely the release we published? Ed25519 over the raw bytes, * matching the detached `.sig` the release job uploads next to it. * * Returns false rather than throwing for a malformed signature: a corrupt or * truncated download is an ordinary outcome here, not an exceptional one, and it * must land in the same "do not install" branch as a wrong one. */ export declare function verifyInstallerSignature(installer: Buffer, signature: Buffer): boolean; export interface OutcomeInput { /** `systemctl is-active` at the end, verbatim. */ activeState: string; /** MainPID read BEFORE the installer ran — the process we are replacing. */ pidBeforeInstall: string; /** MainPID sampled twice after the install, STABILITY_MS apart. */ pidBefore: string; pidAfter: string; /** What the on-disk binary reports now, and what we asked for. */ installedVersion: string; targetVersion: string; } export type Outcome = { ok: true; } | { ok: false; reason: string; }; /** * Did the upgrade actually take? Three conditions, and the VERSION one is the * point: "the service is running" is true after a failed install too, because the * old agent never stopped. A check that cannot tell those apart reports success * for an upgrade that did not happen — which is exactly what happened here on the * first attempt. * * The pid pair catches the other shape of failure: a new binary that starts, * crashes, and gets restarted forever by `Restart=always`. Sampled across a gap, * a flapping unit shows two different MainPIDs while `is-active` still says * `active` at both ends. */ export declare function updateOutcome(input: OutcomeInput): Outcome; /** * Is the lock file a live claim, or the corpse of a worker that was killed? * * The lock lives in a DURABLE directory and its holder can die without unlinking * it — OOM, `systemctl kill`, a host reboot mid-update, or the very cgroup kill * this module exists to escape. Honouring it blindly turns one dead process into * a machine that refuses to update FOREVER, and refuses it quietly: the refusal * only reaches the log, while the parent has already told the caller the update * started. That is a worse failure than a rare double-run. * * `maxAgeMs` is a backstop for the case pid checks cannot cover — after a reboot, * the recorded pid may well be alive again as something else entirely. */ export declare function lockVerdict(content: string, ageMs: number, isAlive: (pid: number) => boolean, maxAgeMs?: number): "held" | "stale"; /** * The relay this agent actually talks to, read from its systemd unit. * * `web/install` bakes the override into the unit as * `Environment="AICOMMANDER_SERVER=…"`, and that is the only durable record of it. * Taking it from the CLI's own environment instead is wrong in the exact case it * matters: run as documented (`sudo aicommander-agent self-update`), sudo's * `env_reset` strips the variable, self-update falls back to production, fetches * the production installer — and that installer REWRITES the unit's Environment * line, moving a staging box onto the production relay as a side effect of * upgrading it. * * Input is the raw `systemctl show -p Environment aicommander-agent` line. */ export declare function serverUrlFromUnitEnvironment(showOutput: string): string | null; export type TargetVerdict = { proceed: true; warning?: string; } | { proceed: false; reason: string; }; /** * Should we install `target` over `current`? * * The interesting case is DOWNWARDS. An old release carries a perfectly valid * signature — signing proves provenance, never freshness — so the signature check * cannot tell a genuine upgrade from a replayed older one. A `/dist/latest` that * is stale, misdeployed, or attacker-controlled would otherwise walk a whole fleet * back onto a version whose bugs are public knowledge, one machine at a time, with * every cryptographic check passing. * * So the version pointer may only ever move an agent FORWARD on its own authority. * Going back is a decision a person makes, with --force, and it is logged as what * it is. */ export declare function evaluateTarget(target: string, current: string, force: boolean): TargetVerdict; /** * Put `source` at `target` even when `target` is a RUNNING executable. * * `copyFileSync` opens the destination for writing, which Linux refuses with * ETXTBSY while the file is being executed — and that is precisely the state * during a rollback: the failed new agent is running from that path. The throw * would escape before the restart, so the one code path whose entire job is to * rescue an unreachable machine would be the one that fails on it. * * Writing beside it and renaming over it is atomic, permitted while the old * inode is still executing (the running process keeps it), and leaves no window * in which the path holds a half-written binary. */ export declare function replaceExecutable(source: string, target: string): void; export interface SelfUpdateOptions { /** Update even when the published version equals the running one. */ force?: boolean; /** * Explicit relay origin. Undefined means "ask the unit, then fall back to * production" — see serverUrlFromUnitEnvironment for why the caller's own * environment is the wrong source. */ serverUrl?: string | undefined; } /** * CLI entry. On the first call this DETACHES and returns immediately; the child * does the work. * * The detach is not a nicety. The installer restarts the service, which kills the * agent — and with it any remote_exec session that started this command. A * foreground upgrade therefore dies at its most dangerous moment: binary swapped, * service restarting, nothing left to verify or roll back. Detached, the caller * losing its connection is expected and harmless; the child finishes alone and * writes its verdict to the log. */ export declare function cmdSelfUpdate(opts: SelfUpdateOptions): Promise;