diff --git a/docs/architecture.md b/docs/architecture.md index b8ea15c85..e704cab62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -431,7 +431,8 @@ the proof is reported with its pid and left running. When a proven hackd cannot be confirmed stopped, `stop` exits 1 and says it is still running, and `restart`, `start` and the `install` handover stop there. A pid file that survived an unclean shutdown and now names another program does not count as a running hackd. If -systemd runs an incompatible hackd from a unit that points at another `hack`, +systemd runs an incompatible hackd from a unit that points at another `hack` +(another file, not a link to this one), `hack daemon status`, `hack doctor` and `hack daemon restart` all point to `hack daemon install`, because a restart would start the same version again. `install` reports success only when systemd runs the hackd that answers. The unit: @@ -453,7 +454,19 @@ sure `$XDG_CONFIG_HOME`, `systemd/` and `systemd/user/` are directories you own that your group and other users cannot write: it creates them `0755` whatever the umask and removes group or other write from ones you own, and it refuses (with the `chmod` to run) when that is not possible, because whoever can replace -the enabled unit runs code as you at the next login. For the same reason, +the enabled unit runs code as you at the next login. Only an explicit +`install` repairs those modes. Every automatic path (`start` and `restart`, +the reconcile after `hack update`, the check before a running daemon is +stopped) refuses a group- or other-writable unit or drop-in directory with +`blocked: "untrusted-unit"` and changes nothing. While such a directory was +writable, another account may already have planted a unit there. That holds +inside an automatic rewrite too. The installer it shares with `install` +repairs nothing and does not replace an untrusted unit file unless the +caller is explicit `install`, so a directory that becomes writable after the +earlier checks refuses the rewrite. Nothing is written, reloaded or +restarted, and the mode is left as it is. +`hack daemon stop` neither repairs nor refuses such a directory (see below). +For the same reason, `install` and `start` check `hackd.service.d/` and `service.d/`: every drop-in there must be a file you own that others cannot write, or they refuse to use the unit until you review it. `start` (and autostart) also runs the unit only when @@ -522,19 +535,25 @@ several `ExecStart=` lines make the program unknown: `start` leaves that unit as it is and says so, and `hack daemon status` and `hack doctor` report it. An unknown program is never treated as a missing one. -`hack daemon start` and autostart never rewrite a unit whose program still exists, -even when the `hack` on the current `PATH` is a different one: they say so, and +`hack daemon start` and autostart never rewrite a unit that runs another existing +`hack` (not this CLI's executable or a link to it): they say so, and `hack daemon install` switches the service. If systemd runs an incompatible hackd from such a unit, `start` reports it instead of replacing it, because -systemd would only start the same version again. Only when the unit's program no -longer exists may `start` repair it, and only to a durable location: the path +systemd would only start the same version again. A stale unit that runs this +CLI's executable and starts with Hack's managed header is rewritten and +restarted; see +[After an upgrade](#after-an-upgrade-a-stale-service-is-repaired-by-start). When the unit's program no +longer exists, `start` retargets it, but only to a durable location: the path and the file it resolves to must both be outside temporary directories, package caches and source checkouts. The unit keeps the path as written. It then replaces just the program in `ExecStart` and keeps every other line, including your edits; otherwise it reports the problem and asks you to run `hack daemon install`. If an earlier `daemon-reload` failed (`NeedDaemonReload=yes`), `start` and `install` reload before they start. Other edits to the file show up as drift in -`hack daemon status` and `hack doctor`, and `hack daemon install` replaces them. +`hack daemon status` and `hack doctor`. `hack daemon install` replaces them, and +so does `hack daemon start` when the unit runs this CLI's executable and still +starts with Hack's managed header. Put local changes in a drop-in +(`systemctl --user edit hackd`). A systemd user manager runs only while you have a login session, unless linger is on. `hack daemon install` and `hack daemon status` report linger. When it is off, @@ -563,6 +582,455 @@ or reloads the unit (`hack daemon` runs with `HACK_DAEMON_SERVICE_READ_ONLY=1`). default `~/.hack` root: an isolated `HACK_HOME` or `HACK_SYSTEMD_DISABLE=1` turns systemd management off (`HACK_SYSTEMD_ALLOW_ISOLATED_HOME=1` opts in). +### After an upgrade: a stale service is repaired by `start` + +`hack daemon start` and `hack daemon restart` bring an installed login service up +to the running CLI. The service is stale when: + +- `content`: the unit (or launchd plist) differs from what this CLI writes for + the same program, for example after an upgrade changed the unit's `PATH`; +- `program-missing`: the program it runs no longer exists; +- `version-mismatch`: the hackd it runs reports another version than the CLI. + +On Linux, `start` rewrites a `content`-stale unit automatically only when both +of these hold: + +- the unit runs this CLI's executable: a path that resolves (every symlink + followed) to a file with the same device and inode as this CLI's, checked + when the decision is made. Equal path strings alone do not count, because a + pathname can be replaced after the CLI started from it; +- its main file starts with Hack's managed header + (`# Managed by hack: written by \`hack daemon install\`...`), whose next line + says local changes are replaced on the next install. + +"This CLI's executable" means the binary that is running, not the `hack` first +on `PATH`. `hack update` runs the new binary by its path while an older `hack` +may come first on `PATH`, and a service running that older one is not this CLI. +A source invocation (`bun index.ts`) has no executable a unit or plist could +name, so it never counts as running the service's program. The `hack` on `PATH` +is still what `hack daemon install`, and a retarget of a missing program, write +into the service. When the service runs another program and the daemon is of +another version, `start` refuses (`blocked: "other-program"`) with +`hack daemon install` as the next step instead of restarting it into the same +version. The launchd checks use the same rule. + +A unit without that header was written or edited by hand. It keeps the earlier +behaviour: `start` reports the drift with `hack daemon install` as the next step +and does not rewrite it. A managed unit whose main file sets the service's Hack +home (`HACK_HOME` or `HOME` in `Environment=`, or any `EnvironmentFile=`) is not +rewritten either, because the unit Hack renders would drop that binding and move +the service to the default home. Neither is a managed unit with any continued +line (a line ending in a backslash, which systemd joins with the next one): Hack +does not re-read the joined setting, so it cannot rule out a binding there. Move +the setting into a drop-in +(`systemctl --user edit hackd`), then run `hack daemon install`. + +Trust comes first on every path that changes the unit. Before `start` reads the +unit to keep its other directives, rewrites it, retargets a missing program, +completes a pending `daemon-reload` or restarts the service, and before it stops +a running daemon to make room for the service, it runs the checks that `install` +and `enable` run. The unit file must be a regular file owned by this user and not +writable by others, and the directories above it and its drop-in directories +must be safe from other accounts. Every user-unit load directory systemd reports +(`UnitPath`) must be safe too, as must the fragment and drop-ins systemd has +loaded, because a rewrite of the main file keeps them. If any check fails, +nothing is written, reloaded, stopped or restarted, and `start` exits 1 with +`blocked: "untrusted-unit"` and `hack daemon install` as the next step. The check +must come first because a rewrite replaces the file as this user: a check after +it would pass for directives another account planted, such as a `Wants=` of +its own unit, which a retarget keeps. + +systemd also starts every unit listed in `hackd.service.wants/`, +`hackd.service.requires/` and `hackd.service.upholds/`, in any user-unit load +directory, together with hackd. Hack's renderer and `install` create none of +these (`systemctl enable` links into `default.target.wants/`). So before any +reload, start or restart, each must be absent, or an empty directory owned by +you or root that others cannot write. A link, any entry in one, or a writable +one refuses with `blocked: "untrusted-unit"`, and nothing is reloaded, +started or restarted. `install` makes the same check. `hack daemon stop` does +not, and is not blocked by a writable unit directory either. A stop loads and +runs no new unit code and Hack never reloads to stop, so it should not stop +you from shutting hackd down exactly when something looks wrong. Stop keeps +its own checks: that the daemon it stops is this home's, that the unit file is +a regular file you own that others cannot write, that the files systemd has +loaded are trusted, and that there are no stop hooks. + +The rewrite keeps the program path as written, so a stable symlink stays a +symlink, and an enabled unit stays enabled while a disabled one stays disabled. +It makes the same checks as `install`: the program and every directory on the +unit's `PATH` must be safe from other accounts, and the unit directories are +checked and created the same way. A program at a location that may not last is +reported, as `install` does. When hackd is running under the unit, `start` reads +the unit's Hack home again after the `daemon-reload`, refuses if it no longer +serves this home, and otherwise restarts it with `systemctl --user restart`, +after the loaded-file, program and stop-hook checks that `stop` makes. A hackd of +another version under this CLI's unit is restarted the same way, even when the +unit was not rewritten, so the new executable runs. "This CLI's unit" means +both the main file and what systemd has loaded: the single loaded `ExecStart` +(drop-ins included, read with `systemctl --user show`) must be this CLI's +executable by file identity. A drop-in that runs another `hack` keeps the +daemon as it is (`blocked: "other-program"`). A loaded `ExecStart` that cannot +be read (none, or several) keeps it too (`blocked: "unproven-owner"`). This +check gates every automatic restart, whatever asked for it: a version mismatch, +a pending-restart marker (which then stays), or a rewrite. Retargeting a +missing program, or rewriting the unit, changes what systemd loads, so the +loaded `ExecStart` is read again after the `daemon-reload` and before the +restart. A retained drop-in that still selects another program stops the +restart there. While a reload is pending (`NeedDaemonReload=yes`) for a unit +file that runs this CLI, which happens after a repair whose `daemon-reload` +failed, the loaded copy is stale by definition. `start` then retries the +reload first (not in read-only mode, with the same home check) and judges the +newly loaded `ExecStart` before restarting, so a later start finishes the +repair. + +Before such a restart, and again just before it lands, `start` proves that the +unit's live main process is this home's hackd. This is the same proof `stop` +needs: a hackd holding a socket in this home's `daemon/` directory, or this +home's hackd as the direct child of a launcher main process. That this home's +pid runs in the unit's cgroup is not enough. This home's hackd may have been +started by a job of another home's daemon, or a reload may have pointed the +configuration at this home while the running daemon serves another. Without the +proof, `start` exits 1 with `blocked: "unproven-owner"` and changes nothing. +The final proof uses a fresh read of the unit's state. If that read fails or +systemd is unavailable, the restart is refused the same way; an earlier read +is never reused, because a failed read reports no main process. +When an earlier repair wrote the unit but its `daemon-reload` failed, the next +`start` finishes that reload (and checks the unit's home again) before it +restarts. If the reload fails again, the restart is not attempted and the +restart-pending marker stays for the next run. + +`start` never rewrites or restarts the service from inside it. On Linux it +compares its own cgroup (`/proc/self/cgroup`) with the unit's `ControlGroup`. On +macOS it checks whether the launchd job's process is one of its ancestors. A job +that hackd started (for example a supervisor job running `hack daemon start`) +would otherwise stop itself. From inside, a compatible daemon is kept: `start` +exits 0 with action `none` and `blocked: "inside-service"`. A daemon of another +version is refused with exit 1 and nothing is stopped. The check fails closed: +`start` treats the caller as inside when systemd reports no `ControlGroup` (or +an empty one) for the running unit, when `/proc/self/cgroup` is unreadable, +empty or malformed, or when launchd reports no usable pid for the loaded job or +the ancestry cannot be read. Known limit: an explicit `hack daemon restart` run +from inside the service is not guarded, and stops the service it runs in. + +`start` does not repair the service, and goes on as before, when it cannot +prove the service is this CLI's: + +- the unit runs another existing `hack` (one you may have chosen): it is not + rewritten; a running hackd of another version is refused with + `hack daemon install`, which switches the service; +- systemd cannot say what the unit runs (an `ExecStart` Hack does not read): a + running hackd of another version is refused; +- the unit serves another Hack home, or Hack home isolation + (`HACK_SYSTEMD_ALLOW_ISOLATED_HOME`, `HACK_SYSTEMD_DISABLE`) keeps this CLI + off the user service: hackd is started directly, as before; +- `HACK_DAEMON_SERVICE_READ_ONLY=1` (`hack doctor --fix` without a TTY): the + unit is never written or reloaded. A hackd of another version under this + CLI's unit is still restarted, which does not change the unit. A pending + reload or restart stays pending and is reported (`blocked: "read-only"`), but + on its own it never restarts a hackd that already runs this version, so + repeated read-only starts do not keep restarting it. + +On macOS, Hack reads the plist only at its own LaunchAgents path for its label +(`dance.hack.hackd`). launchd has no drop-ins, so the systemd header rule does +not apply there. A plist counts as Hack's when it carries Hack's managed marker +(a `` comment that `hack daemon install` now writes) +or its `Label` is Hack's. That covers plists written by earlier Hack versions, +which have no marker. + +The program is read as launchd reads it: `Program` when present, else +`ProgramArguments[0]`, and only from keys of the plist's root ``. Keys +inside nested dicts (such as `EnvironmentVariables`) and inside comments do not +count. Some plists cannot be read: + +- the text is not a property list, or its root is not a dict; +- it has CDATA, an unterminated comment, a malformed or unbalanced tag, or a + value type the reader does not know; +- a root key repeats, or uses an XML entity or character reference (launchd + reads `Program` as `Program`). Keys are compared exactly as written, so + ` Program ` with spaces is another key; +- the program value is not a plain string, uses an XML escape, or is not an + absolute path exactly as written (it is not trimmed). + +Such a plist is never replaced automatically; `start` reports it and +`hack daemon install` replaces it. + +- A plist whose program is gone is retargeted to this CLI. Gone means no program + at all, a compiled-binary virtual path, or a path that `stat` reports as + `ENOENT` or `ENOTDIR`. Any other `stat` error (for example `EACCES` or `EIO`) + means the program cannot be checked, and the plist is left alone. The + retarget target (the `hack` on `PATH`) must pass the same checks as a systemd + retarget. It may not be a source file (`.ts`, `.js`...) or a `#!` script run + by bun. It must be durable, outside temporary directories, package caches + and checkouts, and safe from other accounts. Otherwise start refuses + (`blocked: "rewrite-refused"`) with `hack daemon install` as the next step. +- While hackd runs (start found it running, or launchd reports the job + running), a rewrite would unload and restart it. Only an eligible CLI may do + that: a compiled binary that is the same file as the program the plist will + run. Otherwise the job is not touched and the daemon keeps running + (`unproven-owner` from a source checkout, `other-program` for another + binary). A stopped job may still be repaired from any CLI. The same rule + applies before `start` or `restart` stops an incompatible hackd that runs + under a plist whose program is gone. The start after the stop would + retarget the plist, so the target must pass the retarget checks + (`rewrite-refused`), and the CLI must be a compiled binary that is that + target. Otherwise nothing is stopped. +- A plist that runs this CLI's executable (same file) is rewritten with the + marker when it differs from what this CLI writes, if it is Hack's or its + output paths are not `/dev/null` (the earlier output repair). launchd then + restarts hackd from it. One that is not Hack's (no marker and another + `Label`) is only reported. +- Every automatic rewrite, a refresh included, checks the program the new + plist will run last, right before the plist is written and the job is + booted out and bootstrapped. A refresh keeps the plist's program, and that + program must pass the retarget checks: not a source file or bun script, + durable, and safe from other accounts along its whole path. Otherwise + nothing is written, unloaded or loaded, and start refuses + (`blocked: "rewrite-refused"`) with `hack daemon install` as the next step. + Without this check, someone able to replace that pathname would have + launchd start their program as this user. hackd also runs helpers such as + `docker` by bare name, so every directory of the PATH the new plist gives + hackd (`/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`, `/bin`, + `~/.bun/bin` and `~/.hack/bin`) must pass the checks systemd makes for its + rendered unit's PATH. A directory another account can add programs to, or + create, refuses the rewrite the same way, with nothing unloaded or written. + An explicit `hack daemon install` on macOS makes the same PATH check before + it touches launchd. The check that runs before `start` or `restart` stops a + running hackd makes the program and PATH checks for a refresh or retarget + the start would run. +- Hack runs `launchctl` as `/bin/launchctl` and the macOS ancestry check's + `ps` as `/bin/ps`, never looked up on PATH. These run automatically after + `hack update` and during `start`, and a planted `launchctl` or `ps` in an + earlier PATH directory would otherwise run as this user. The daemon's + process proofs, the orphan sweep and the ownership proof an automatic + replacement runs, read `ps` and `lsof` only from fixed paths. On macOS + those are `/bin/ps` and `/usr/sbin/lsof`. On Linux they are the first of + `/bin/ps` or `/usr/bin/ps`, and of `/usr/bin/lsof` or `/usr/sbin/lsof`, + that another account cannot replace. When none qualifies, the observation + is unknown: nothing is stopped, and the start does not go on as if no + orphan held the socket. On Linux, `systemctl`, `loginctl` and `getent` are + still found on PATH, but one whose path another account could replace is + refused before it runs. +- Every automatic start of the launchd job checks the plist launchd will + load or start, current or just rewritten, before anything is unloaded, + bootstrapped or kickstarted. That covers `start`, `restart`, the reconcile + after `hack update`, a stopped-service start and autostart. The plist must: + - run exactly ` daemon start --foreground`: its complete + `ProgramArguments`, with `Program` taking precedence as launchd does; + - name a program that is safe from other accounts along its whole path; + - set its own `PATH`. launchd's default or inherited PATH is never assumed, + because it can hold a directory another account writes. + + Every directory of that `PATH` must pass the same PATH checks. Hack has + always rendered `PATH` and that exact command (the first launchd renderer + already did). A Hack plist without them is refreshed through the checked + repair, which writes them. Any other such plist is not started + automatically. +- A kickstart runs the configuration launchd has loaded, which can differ + from the plist on disk until the job is reloaded. The loaded job is read + from `launchctl print gui//dance.hack.hackd`. It must run exactly the + checked plist: the same program, Hack's arguments, and the same explicit + `PATH` in the job's own environment. When it does not, or cannot be read, + the job is reloaded from the checked plist, but only if a status read made + just before the bootout shows it stopped. A running job is not reloaded, + because that would stop hackd. After a successful reload, a re-read that + positively differs still refuses. A re-read that cannot be parsed is + accepted, because launchd has just loaded the plist that passed every + check. Reading real `launchctl print` output is a compatibility point that + has not been verified against every macOS release, and this fallback keeps + the service available if that format moves. +- The plist file itself is checked before anything is unloaded, written or + bootstrapped: install, refresh, retarget, reload and autostart's implicit + install. It must be a regular file (not a link), owned by this user and + not writable by group or others. `~/Library/LaunchAgents` must not let + others add or replace entries, and no directory above it may be + replaceable by another account. Nothing is repaired, because a file others + could write may already hold their content. A failure refuses with + `blocked: "untrusted-unit"`. The bytes are bound too: + - a reload reads the plist through a descriptor that does not follow + links, and those bytes must be exactly the ones checked; + - right before `launchctl bootstrap` it reads the plist the same way + again, and requires the same bytes, device and inode; + - an install writes a fresh file renamed onto the path (a link there is + refused) and binds the bootstrap to those bytes the same way. + + A plist replaced in between is not loaded, and so not kickstarted. +- On a refusal launchd is not asked to start the job, and `start` exits 1 + (`blocked: "rewrite-refused"`, or `untrusted-unit` for the plist file, + with `hack daemon install` as the next step). + Before `start` or `restart` stops a running hackd under a current plist, + it makes the same plist checks and refuses a loaded configuration it cannot + read. So a running daemon is never stopped for a start that would be + refused. Every plist install (explicit, autostart's implicit install, + refresh or retarget) also checks the program it writes, safe from other + accounts along its whole path, before it unloads, writes or loads anything. +- On Linux, a stopped-service start checks the loaded unit's programs and + effective PATH just before `systemctl start`. The program a rewritten unit + keeps passes install's program checks just before the unit is written. An + automatic restart checks the exact loaded program and the effective PATH + as its last step. +- A plist that runs another existing `hack` is left as it is, even an older + versioned install path. A running hackd of another version under it is + refused with `hack daemon install`. A stopped service under it is not + kickstarted (earlier Hack versions retargeted such a plist to the running + CLI instead), and `hack daemon restart` refuses before it stops anything. +- When a needed rewrite does not complete (unloading, writing or loading the + plist fails), `start` exits 1 with `E_DAEMON_SERVICE` instead of reporting + hackd as running: the unload may already have stopped it. +- Before any automatic rewrite (a refresh or a retarget), the loaded job must be + this home's. If the plist's `EnvironmentVariables` sets `HACK_HOME`, or a + `HOME` other than the one Hack writes for this home, it is left alone + (`blocked: "home-binding"`). If the job is running, its pid must be proven to + be this home's hackd, holding a socket in this home's `daemon/` directory + (the proof `stop` uses). Otherwise it is left alone + (`blocked: "unproven-owner"`). A running job without a pid counts as + unproven. A job counts as not loaded only when `launchctl print` says so + itself: exit 113 with "Could not find service". Any other failed query is an + observation failure. It is treated as unproven, so nothing is unloaded, + rewritten or loaded. +- When `start` finds this home's hackd running directly and incompatible, it + replaces that daemon by stopping it and starting through the service. + Before it stops anything, it checks that the start would go through. On + macOS the loaded launchd job must not bind another home and must be this + home's. On Linux, the check uses the same planning and unit-rewrite + computation as the start itself, without writing anything. The start would + refuse when: + - the unit is untrusted, its Hack home cannot be read, or Hack cannot vouch + for its loaded files or program; + - a missing program cannot be repaired in read-only mode; + - a needed rewrite would be refused: no durable `hack` to retarget a missing + program to, or a program or service `PATH` that fails install's checks, + including a `PATH` or program that a drop-in the rewrite keeps supplies. + + If the start would be refused, `start` refuses first (exit 1, + `E_DAEMON_SERVICE`, with `home-binding`, `unproven-owner`, `untrusted-unit`, + `read-only` or `rewrite-refused` as `blocked`) and leaves the running daemon + alone. + `hack daemon restart` runs the same checks before it stops a running hackd. + Its start after the stop then gets the same direct fallback as a replacement + in `start`. + + The check cannot foresee everything: a change between the check and the + start, or a write, reload or start that fails at runtime. So when the service + cannot start the replacement after the stop, `start` starts this home's hackd + directly instead, the way it does when the unit serves another home. It + reports action `restarted` with `fallback: "direct"`, keeps the service's + `blocked` reason, and warns to run `hack daemon install` (a directly started + hackd stops at logout or reboot). Only if that direct start fails too does + hackd stay stopped, and `start` reports the error. The post-update + reconcile-only mode uses the fallback as well, because that daemon was + running before. + + A replacement's direct start runs this CLI itself: the executing compiled + binary, or the same runtime and entry point for a source invocation. It + never runs the `hack` first on `PATH`. `hack update` runs the new binary by + absolute path while an older `hack` may come first on `PATH`, and starting + that one would bring back the old version. After any replacement start, + through the service or directly, `start` checks that the daemon answers on + this home's socket and reports this CLI's version. A pid and a socket are + not enough. If it does not, the replacement failed: `start` exits 1 with + `E_DAEMON_SERVICE` and action `none`, never `restarted`, and the update + result records `daemon.status: "failed"`. Ordinary starts with no running + daemon to replace keep starting the `hack` they always did. + + The fallback covers an early refusal (before the manager was asked to start + anything), a failed start, and a start that throws. It never starts a second + hackd next to one the manager may still start, so it runs only on positive + evidence that the manager is idle: + - systemd: `ActiveState` is `inactive` or `failed`, `SubState` is not + `auto-restart`, and no `Job` is queued (or the unit is not loaded); + - launchd: only when `launchctl print` for Hack's label answers not-found + (exit 113, "Could not find service"). Hack asks launchd even when the plist + file is gone, because moving or deleting the file does not unload the job. + A loaded job is never idle, whatever its plist file says: the loaded + configuration can differ from the file, and `KeepAlive`, `RunAtLoad`, + `StartInterval`, `StartCalendarInterval` or `WatchPaths` can start it + again. On macOS the fallback therefore runs only once the job is unloaded. + + When the manager may still start hackd, or cannot be checked, `start` + returns the service's error with `blocked: "service-pending"` and says the + service may still start hackd. Whether hackd was asked to start comes from + what the start itself reports, not from the `--json` record. + +```mermaid +stateDiagram-v2 + [*] --> Observe + Observe --> Direct: no usable unit, another home, or isolated + Observe --> Plan: unit serves this home + Plan --> Refuse: program gone and read-only + Plan --> Rewrite: program gone + Plan --> Rewrite: content stale, this CLI's program, managed header, no home binding + Plan --> Refuse: version mismatch and unit runs another or unreadable program + Plan --> Refuse: running mismatch and caller inside the service + Plan --> Start: not running + Plan --> Restart: running and version mismatch + Plan --> Keep: running, current, or stale but not rewritable, or caller inside the service + Rewrite --> Start: not running + Rewrite --> Recheck: running + Recheck --> Restart: unit still serves this home + Recheck --> Refuse: unit now serves another home + Direct --> [*] + Start --> [*] + Restart --> [*] + Keep --> [*] + Refuse --> [*] + classDef ok stroke-width:1.6px + classDef live stroke-width:1.6px + classDef fail stroke-width:1.6px + class Keep ok + class Rewrite live + class Restart live + class Refuse fail +``` + +After `hack update` installs a release, it checks for an installed login service +and for this home's hackd. A live pid is not enough: hackd must answer on this +home's socket. When both are found, it runs the new executable's +`hack daemon start --json` in reconcile-only mode (`HACK_DAEMON_RECONCILE_ONLY=1`). +In that mode `start` does nothing (action `none`) when no hackd of this home is +running, so a daemon that stopped in the meantime is not started. The flag +applies to that one invocation only. A daemon it starts to replace an +incompatible one never inherits it, and a `--foreground` start ignores it, so +the replacement runs normally. The step never +fails the update, including when it cannot read the service state. A failure is +reported in the update result (`daemon.status: "failed"`) with +`hack daemon install` as the next step. A stopped service picks up the new +version at its next `hack daemon start`. + +The new executable is run only after two checks, made right before the +spawn: +- its path must be safe from other accounts (the program checks a login + service gets), with no link at the final component; +- it must still be the file `hack update` published: the same device, inode + and sha256 that the publish step recorded. + +With a custom install directory that another account can write, a payload +could otherwise be renamed over the binary and run as the updating user. If +either check fails, nothing is run, and the update result records +`daemon.status: "failed"` with `hack daemon start` as the next step. The +update itself still succeeds. + +`hack daemon start|stop|restart|install --json` print one envelope and nothing +else on stdout (human lines go to stderr). They follow the shared rule in +[Machine-readable output](reference/machine-readable-output.md): exactly `ok` +and `data` at the top level, with the versioned schema inside `data`. Success +is `{"ok": true, "data": {...}}` with: + +| Field | Meaning | +| --- | --- | +| `schema` | `hack.daemon-.v1` | +| `manager` | `systemd`, `launchd` or `none` (hackd runs directly) | +| `action` | `none`, `started`, `restarted`, `reinstalled`, `stopped` or `installed` | +| `stale`, `stale_reasons` | whether the service or daemon was stale, and why: `content`, `program-missing`, `version-mismatch` | +| `blocked` | why a stale service was left as it is, or `null`: `read-only`, `other-program`, `unknown-program`, `unmanaged`, `home-binding`, `inside-service`, `unproven-owner`, `rewrite-refused`, `service-pending`, `untrusted-unit` | +| `fallback` | `"direct"` when a replaced daemon was started directly because the service could not start it, else `null` | +| `cli_version` | this CLI's version | +| `daemon` | `running`, `pid`, `version` and `compatible` after the command | + +A failure is `{"ok": false, "error": {"code": "E_DAEMON_SERVICE", "message", +"detail"}}`, where `message` is the last warning or error the command printed and +`detail` is the same data. Access refusals keep their own code (`E_PERMISSION`), +and `start --foreground --json` is `E_USAGE`. Exit codes are unchanged. + ## Native backend internals The native backend is built around explicit ownership: every run has one owner, diff --git a/docs/cli.md b/docs/cli.md index 6dc1419c5..a6066a857 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ terminal). - `hack session` — persistent project workspaces (tmux-first) - `hack doctor` / `hack doctor --fix` — validate and repair local setup - `hack upgrade check` — read-only upgrade check of this machine and every registered project: a one-screen summary, the projects with findings (worktrees folded; `--verbose` for every checkout), and a plan with one step per kind of change, then the next command; `hack upgrade apply [--only ] [--project ] [--dry-run]` runs the plan's apply steps (`.hack/.gitignore` refresh, stale agent files) for every checkout after keeping a snapshot and a journal; `hack upgrade snapshot` and `hack upgrade rollback --rollback-manifest ` keep and restore the files the upgrade changes -- `hack daemon` — optional local daemon for faster JSON project status; `hack daemon install` starts it at login (launchd on macOS, a systemd user unit on Linux). On Linux without linger it still stops at your last logout and does not start at boot; `loginctl enable-linger` changes that +- `hack daemon` — optional local daemon for faster JSON project status; `hack daemon install` starts it at login (launchd on macOS, a systemd user unit on Linux). On Linux without linger it still stops at your last logout and does not start at boot; `loginctl enable-linger` changes that. After an upgrade, `hack daemon start` (and `hack update`) restarts hackd on the new version when its login service runs the same executable, and rewrites a stale unit or plist that Hack wrote (managed header); `start`, `stop`, `restart` and `install` accept `--json` Candidate `hack-next doctor` and `hack-v5 doctor` report a missing hackd as optional for Compose workflows. They do not recommend starting the stable daemon or diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ade17b5f2..184cbd82d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1736,7 +1736,7 @@ hack daemon [options] | Command | Summary | | --- | --- | -| `hack daemon start` | Start hackd (local daemon) | +| `hack daemon start` | Start hackd (local daemon); reinstalls and restarts a stale login service | | `hack daemon stop` | Stop hackd | | `hack daemon restart` | Restart hackd | | `hack daemon status` | Show hackd status | @@ -1756,7 +1756,7 @@ hack daemon [options] ## `hack daemon start` -Start hackd (local daemon) +Start hackd (local daemon); reinstalls and restarts a stale login service ### Usage @@ -1769,6 +1769,7 @@ hack daemon start [options] | Option | Description | | --- | --- | | `--foreground` | Run hackd in the foreground (debug) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -1787,6 +1788,7 @@ hack daemon stop [options] | Option | Description | | --- | --- | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -1805,6 +1807,7 @@ hack daemon restart [options] | Option | Description | | --- | --- | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -1901,6 +1904,7 @@ hack daemon install [options] | `--no-run-at-load` | Do not start hackd automatically on login (macOS launchd) | | `--gui-only` | Only run in GUI sessions (macOS launchd; default) | | `--no-gui-only` | Run in all session types, including SSH (macOS launchd) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 0a5a2df7b..8d459f034 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -59,7 +59,11 @@ success behavior stays covered by command-specific suites. | `hack config import --json` | Legacy bare object (no `data` envelope) | | `hack config plan --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | | `hack config validate --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack daemon install --json` | Envelope; versioned `data.schema`: `hack.daemon-install.v1` | +| `hack daemon restart --json` | Envelope; versioned `data.schema`: `hack.daemon-restart.v1` | +| `hack daemon start --json` | Envelope; versioned `data.schema`: `hack.daemon-start.v1` | | `hack daemon status --json` | Legacy bare object (no `data` envelope) | +| `hack daemon stop --json` | Envelope; versioned `data.schema`: `hack.daemon-stop.v1` | | `hack dispatch logs --json` | Legacy bare object (no `data` envelope) | | `hack dispatch run --json` | Legacy bare object (no `data` envelope) | | `hack dispatch status --json` | Legacy bare object (no `data` envelope) | diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 88f5ec35e..ea0d1f9a1 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -71,6 +71,7 @@ must be attached to the eventual exact integration revision. | Compose detached startup: short job exits before observation; old completed container replaced between samples | **Coverage gap:** no maintained model of external Docker observation consistency | `waitForComposeStartupState` corroborates identities and classifies only the latest snapshot within the remaining start budget. `compose-startup-state.test.ts` covers sub-second interleavings, replacement, failure and deadline; `project-up-startup-state.test.ts` replays the Compose/Docker boundary. `compose-short-oneshot` checks real repeated replacement and sub-second exit zero | Assumes Docker listings eventually describe current containers; two matching identity listings cannot prevent replacement after the final read. Unit bounds are one job, two identities and a 1 s virtual budget. Real-project acceptance and Docker CI remain separate gates. | | Compose foreground first/second interrupt and lifecycle signal handoff | **Coverage gap:** no maintained model covers the external Compose shutdown protocol | `composeRuntimeBackend.up` isolates the Compose session, forwards each signal once, and waits for child exit/log EOF; `runUpCommand` retires the setup signal owner before attached launch. `tests/compose-foreground-signal.test.ts` covers delayed graceful completion, second interrupt and listener disposal; `tests/e2e/scenarios/compose-foreground-signal.ts` checks both real containers immediately after a process-group SIGINT and CLI exit. | Finite subprocess tests cover one or two interrupts and one child; Docker shutdown success is observed by the live regression, not inferred from child exit. Docker daemon failure, wrapper SIGKILL/crash and an unresponsive Compose client are not qualified. The first interrupt has no forced deadline; Compose owns its configured service stop grace periods. | | HACK-2184: foreground lifecycle process groups retire before successful cleanup/state removal | **Coverage gap:** no maintained model covers mux acknowledgement versus external group exit | `abortOwnedSession` awaits `createLifecycleSessionRetirement`; capture owned pane descendants before mux teardown, settle command groups before wrapper/reader groups, then remove owned state. `terminateLifecycleProcessGroups` observes absence after TERM and after its one KILL escalation. `tests/lifecycle-group-settlement.test.ts` uses deterministic time/signal boundaries; `tests/project-lifecycle-controller-ownership.test.ts` blocks retirement and verifies cleanup/state cannot complete early. Removing either wait boundary fails its regression. `tests/lifecycle-fifo-retirement.test.ts` exercises real delayed and TERM-resistant FIFO writers plus a separately grouped cooperative worker; only groups outside the wrapper’s forwarded TERM receive an additional identity-checked TERM; `repairProjectLifecycleSessions` uses the same ordered capture/retirement before removing an orphan entry, and `tests/lifecycle-hygiene-retirement.test.ts` gates state removal, retains uncertainty, continues other repairs and exercises a real TERM-resistant FIFO writer; the Docker foreground scenario requires separate app/worker ready markers, polls both named running containers before Ctrl-C, and verifies the one-second TERM handler finished without KILL. `tests/compose-foreground-readiness.test.ts` rejects replayed single-service markers and stale container listings; settlement, absent-session and repair tests cover zombie-only retirement and mixed live members. [Statechart](../diagrams/compose-lifecycle-retirement.svg), [source](../diagrams/compose-lifecycle-retirement.mmd). | Each group phase has a 5 s initial-observation retry budget, 5 s grace and 2 s confirmation after one KILL; each census can overshoot by 2 s. Two phases allow up to 48 s including bounded signal-identity retries, plus capture/mux/optional birth refresh (see lifecycle.md timing table). Second Ctrl-C skips command grace, not fences, exit confirmation or the wrapper reap window; the foreground guard also counts interrupts during Compose shutdown. Failed censuses retry within their deadline; no successful census remains unknown. ESRCH or a successful census containing only zombie members proves retirement; probe EPERM falls through to the zombie-aware census (including macOS zombie-only groups). Delivery EPERM is benign only after observed retirement; a live member refuses, and failed reads retry within the same phase deadline. Other probe errors retain uncertainty, ownership and dependent helpers. Deterministic EPERM-to-ESRCH, mixed-live and denied TERM/KILL cases plus a real held-zombie fixture on macOS/Linux cover these branches; Linux execution does not qualify macOS. Deterministic cases cover selective TERM for two groups; real FIFO fixtures cover a main command waiting for a separate worker group, and the Docker fixture covers two services and one persistent command. OS scheduling, PID reuse outside the active ownership interval, CLI SIGKILL/crash, escaped descendants and zellij retirement are not qualified by these tests. Native launch abandonment excludes canceled startup inputs and budget from missing-birth refresh, while rechecking admission, hook ownership, directory pins, saved owner and mux token; normal start/stop retains full input freshness. A portable adapter regression cancels both startup-input authority and budget; a real adapter regression holds and fails the launch census, cancels, then requires owned mux and state removal. The native recovery fixture retries probe EPERM until ESRCH instead of mistaking the error for absence. FIFO and held-zombie files run in both hosted OS pools. Owned foreground cleanup refreshes only missing births through current pane membership with owner-token checks on both sides; stored births and replacement refusals remain strict. tests/lifecycle-cleanup-followup.test.ts and the controller launch-read failure regression cover refresh, second-interrupt propagation and shared-helper fences. Zellij tracking is dropped because HACK-2215 replaces the lifecycle wrapper and zellij is scheduled for removal in v5.0.0. `up.after` after successful attached Compose return and explicit down hooks are inherited v4.3.0 ordering, documented in lifecycle.md. | +| hackd service reconcile across upgrades (HACK-2216): never stop or restart another home's daemon; never stop a daemon unless its replacement can start or the direct fallback is allowed; nothing reloaded, kickstarted or bootstrapped before trust is established; an update never starts a daemon that was stopped | **Coverage gap:** no maintained model; not model-checked. | Source transitions: the plan decision in `src/daemon/reconcile.ts` (`planDaemonReconcile`, closed `blocked` reasons); systemd prepare and restart in `src/commands/daemon-systemd.ts` and launchd prepare and kickstart in `src/commands/daemon-launchd.ts`; the pre-stop check before a replacement (`refuseSystemdStartBeforeReplacing`, `refuseLaunchdBeforeReplacing`); the final ownership proof and last program and PATH check before a restart (`proveOwnershipBeforeRestart`, `refuseUntrustedProgramLast`); the launchd kickstart gate (`verifyLaunchdJobBeforeKickstart` in `src/daemon/launchd.ts`, loaded job read by `src/daemon/launchd-print.ts`, plist trust and byte binding); the `startReplacementDaemon` fallback in `src/commands/daemon-replace.ts`, gated on the manager being idle and verified against this CLI's version; restart-pending marker recovery; and the post-update hook in `src/commands/daemon-upgrade.ts`, which runs reconcile-only and binds the binary `publishRelease` installed. Regressions: pre-stop refusal before any stop (`tests/daemon-systemd.test.ts`, `tests/daemon-reconcile.test.ts`, `tests/launchd-isolation.test.ts`); final-proof refusal (`tests/daemon-systemd.test.ts`); pending-reload recovery over two runs (`tests/daemon-systemd.test.ts`); replacement version verification and the fallback only on an idle manager (`tests/daemon-reconcile.test.ts`); a plist swapped between check and reload (`tests/launchd-isolation.test.ts`); reconcile-only never starting a stopped daemon (`tests/daemon-reconcile.test.ts`, `tests/daemon-systemd-cli.test.ts`); post-update binary binding (`tests/daemon-reconcile.test.ts`, `tests/upgrade-installer.test.ts`); launchctl print parsing (`tests/launchd-print.test.ts`); fixed `ps` and `lsof` paths (`tests/system-tool.test.ts`). | No finite model and no finite bound. The tests assume one reconcile at a time per Hack home; concurrent `hack daemon` invocations are not modelled. Uncovered: a crash between the stop and the start, power loss, real systemd and launchd behaviour (the tests use a fake `systemctl` and a stub `launchctl`), real `launchctl print` parsing across macOS releases, and concurrent service manager restarts. The Mac launchd pass is pending. | These rows identify coverage, not closure. A local mutant or spy proves handling, not that the kernel/provider produces the modeled condition. No model establishes diff --git a/docs/versions/v5/guides/upgrade-from-v4.md b/docs/versions/v5/guides/upgrade-from-v4.md index dda9394b6..afe63870d 100644 --- a/docs/versions/v5/guides/upgrade-from-v4.md +++ b/docs/versions/v5/guides/upgrade-from-v4.md @@ -737,8 +737,11 @@ Source: [current env migration and cleanup guidance](https://github.com/hack-dan Installing another CLI does not replace an already running daemon. For an unmanaged daemon, `daemon start` replaces a reachable incompatible daemon; `daemon restart` is the explicit replacement path. With the newer Linux systemd -service, a unit pointing at a different existing executable needs explicit -`daemon install`: restarting that unit would run its old executable again. +service, `daemon start` restarts a stale service that runs the same executable +(or a link to it), and rewrites its unit when Hack wrote it (managed header). A +hand-written unit is reported, not rewritten. A unit pointing at a different existing +executable needs explicit `daemon install`: restarting that unit would run its +old executable again. Keep this separate from a side-by-side candidate trial: the launcher disables daemon autostart, and stable `hackd` stays stopped while the candidate shares its home. The macOS trials confirmed that the stable diff --git a/docs/versions/v5/reference/command-contracts.md b/docs/versions/v5/reference/command-contracts.md index a89243ab2..0c9dd1b59 100644 --- a/docs/versions/v5/reference/command-contracts.md +++ b/docs/versions/v5/reference/command-contracts.md @@ -157,14 +157,14 @@ Source: `src/commands/env.ts:437 (backendUseSpec)`. No command alias is declared | `hack log-pipe` | `--format `; `--stream ` | Present | | `hack doctor` | `--path, -p `; `--fix`; `--migrate-env-config`; `--domain-migration preview\|apply\|rollback`; `--json`; `--browser-url https://app.hack`; `--browser-result unknown\|works\|fails\|permission-denied`; `--branch `; `--native-cleanup inspect`; `--native-run-mapping inspect\|repair`; `--expect-selection <64-hex>`; `--accept-legacy-device-rebind` | Present | | `hack crash-capture` | `--path, -p `; `--since ` | Present | -| `hack daemon start` | `--foreground` | Present | -| `hack daemon stop` | none | Present | -| `hack daemon restart` | none | Present | +| `hack daemon start` | `--foreground`; `--json` | Present | +| `hack daemon stop` | `--json` | Present | +| `hack daemon restart` | `--json` | Present | | `hack daemon status` | `--json` | Present | | `hack daemon metrics` | none | Present | | `hack daemon logs` | `--tail ` | Present | | `hack daemon clear` | none | Present | -| `hack daemon install` | `--run-at-load`; `--no-run-at-load`; `--gui-only`; `--no-gui-only` | Present | +| `hack daemon install` | `--run-at-load`; `--no-run-at-load`; `--gui-only`; `--no-gui-only`; `--json` | Present | | `hack daemon uninstall` | none | Present | | `hack secrets set [name]` | `--service ` | Present | | `hack secrets get [name]` | `--service ` | Present | @@ -332,6 +332,7 @@ Thrown usage errors with an explicit `--json` token become E_USAGE; other thrown | usage | bare projects[], total nullable, host[], host_total nullable, runtime_ok:boolean, runtime_error nullable; details adds containers[] | failures exit 1; optional error string. No samples exits 0 with warning:no_usage_samples. Watch uses the interactive redraw path even with JSON selected; do not treat watch as a JSON report stream. | | global status | bare generated_at/caddy/logging/networks/gateway/summary | exits 0 even if summary health false; gateway is retained unsupported data, excluded from public contract | | daemon status | bare report below | running returns 0; starting/stale/unavailable may return 1 | +| daemon start/stop/restart/install | shared envelope; data schema `hack.daemon-.v1`, manager (systemd/launchd/none), action (none/started/restarted/reinstalled/stopped/installed), stale:boolean, stale_reasons (content/program-missing/version-mismatch), blocked (null/read-only/other-program/unknown-program/unmanaged/home-binding/inside-service/unproven-owner/rewrite-refused/service-pending/untrusted-unit), fallback (null/direct), cli_version, daemon.{running,pid,version,compatible} | failure is `E_DAEMON_SERVICE` with the same data as detail; access refusals `E_PERMISSION`; `start --foreground --json` is `E_USAGE`; exit codes unchanged | | host ps | bare {generatedAt,snapshotAvailable,commands,lifecycle} | timestamp string, availability boolean; nested telemetry remains specialized | | session panes/capture/tail | JSON-lines events below | no shared envelope | | update | command-owned object with ok:boolean, string error on failure | error is not shared error.{code,message}; success varies by check/install outcome; exit follows ok | diff --git a/docs/versions/v5/upgrade.md b/docs/versions/v5/upgrade.md index 133db2413..1932c28ed 100644 --- a/docs/versions/v5/upgrade.md +++ b/docs/versions/v5/upgrade.md @@ -117,8 +117,14 @@ backup scripts, source evidence and checks still awaiting real-machine trials. ## The daemon after an upgrade Installing a CLI does not replace a running daemon. After choosing a durable v5 -install, use `hack daemon restart` for an unmanaged incompatible daemon. If a -login service still points at the old executable, use the chosen install's -`hack daemon install`. Keep this outside the shared-home candidate trial; +install, run its `hack daemon start` (or `hack daemon restart`). When the login +service runs that same executable, start restarts hackd on the new version and +rewrites a stale unit that Hack wrote (it carries Hack's managed header) or a +stale launchd plist with Hack's label, including one an earlier Hack wrote. +`hack update` does this by itself when a service is installed and +hackd is running. A hand-written unit, a unit that sets its own Hack home, or a +service that points at another existing executable is reported instead: use the +chosen install's `hack daemon install`. Add `--json` for one machine-readable result. Keep this +outside the shared-home candidate trial; [daemon and service details](guides/upgrade-from-v4.md#daemon-old-lock-and-missing-checkouts) explain the difference. diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md index a0fa33c14..49da31c7e 100644 --- a/plugins/hack/skills/hack-diagnose/references/error-codes.md +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -41,9 +41,10 @@ stay within the request. | `E_ADMISSION_LIMIT` | Too many background commands are active for this session or Hack home. | `hack commands show --json` for the running commands | | `E_REQUEST_CONFLICT` | A background request key was reused with different inputs; the earlier command is unchanged. | `hack commands show --json` | | `E_JOURNAL_VERSION` | A background command journal was written by a newer Hack journal format. | `hack --version` | +| `E_DAEMON_SERVICE` | hackd or its login service could not be started, stopped, restarted, installed or repaired, or Hack refused because it could not prove the daemon or service belongs to this Hack home and executable. | `hack daemon status --json` | | `E_USAGE` | Arguments or flags were invalid for the command. | `hack --help` | | `E_UNEXPECTED` | An unclassified failure; the message is the only detail. | `hack doctor --json` | --- -Content revision: `d54c6edc4aa7`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `de3cc765916b`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts index 46efe24c2..85b4bbbe8 100644 --- a/src/agents/error-code-reference.ts +++ b/src/agents/error-code-reference.ts @@ -178,6 +178,11 @@ const ERROR_CODE_GUIDES: Readonly> = { "A background command journal was written by a newer Hack journal format.", readFirst: "`hack --version`", }, + E_DAEMON_SERVICE: { + meaning: + "hackd or its login service could not be started, stopped, restarted, installed or repaired, or Hack refused because it could not prove the daemon or service belongs to this Hack home and executable.", + readFirst: "`hack daemon status --json`", + }, E_USAGE: { meaning: "Arguments or flags were invalid for the command.", readFirst: "`hack --help`", diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 4204e5fa9..f5d8cfbfa 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -27,6 +27,10 @@ export const JSON_COMMAND_SHAPES = { activity: "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", + "daemon start": "schema-envelope", + "daemon stop": "schema-envelope", + "daemon restart": "schema-envelope", + "daemon install": "schema-envelope", "dispatch run": "legacy-object", "dispatch status": "legacy-object", "dispatch logs": "legacy-object", @@ -117,4 +121,8 @@ export const SCHEMA_JSON_VARIANTS = [ flags: ["--check", "--observe"], schema: "hack.update-check.v1", }, + { command: "daemon start", flags: [], schema: "hack.daemon-start.v1" }, + { command: "daemon stop", flags: [], schema: "hack.daemon-stop.v1" }, + { command: "daemon restart", flags: [], schema: "hack.daemon-restart.v1" }, + { command: "daemon install", flags: [], schema: "hack.daemon-install.v1" }, ] as const; diff --git a/src/commands/daemon-launchd.ts b/src/commands/daemon-launchd.ts new file mode 100644 index 000000000..590e879b9 --- /dev/null +++ b/src/commands/daemon-launchd.ts @@ -0,0 +1,678 @@ +import { resolveExecutingCliPath } from "../daemon/executing-cli.ts"; +import { + checkLaunchdJobBeforeStart, + checkLaunchdPlistTrusted, + checkLaunchdRetargetTarget, + checkLaunchdServicePath, + getLaunchdServiceStatus, + kickstartLaunchdService, + type LaunchdProgramRepairResult, + type LaunchdProgramState, + type LaunchdServiceStatus, + type LaunchdStartRefusal, + launchdJobMayBeRunning, + plistKeepsThisHome, + proveLaunchdJobOwnership, + readLaunchdLoadedJob, + readLaunchdProgramState, + readLaunchdRefreshTarget, + refuseRunningRewrite, + repairLaunchdProgramIfInvalid, + verifyLaunchdJobBeforeKickstart, +} from "../daemon/launchd.ts"; +import type { LaunchdLoadedJob } from "../daemon/launchd-print.ts"; +import type { DaemonPaths } from "../daemon/paths.ts"; +import { hasAncestorProcess } from "../daemon/process-ancestry.ts"; +import type { DaemonCommandTrace } from "../daemon/reconcile.ts"; +import { readTextFile } from "../lib/fs.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; +import { logger } from "../ui/logger.ts"; + +/** + * macOS `hack daemon start` and `restart` through a loaded launchd agent + * (HACK-2216). launchd effects are injectable so the decisions can be + * driven without launchd. + */ + +export interface LaunchdStartHooks { + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; + readonly waitForReady: (timeoutMs: number) => Promise; + /** Defaults to the real launchd status. */ + readonly readStatus?: () => Promise; + /** Defaults to {@link hasAncestorProcess} for this process. */ + readonly isAncestor?: (pid: number) => Promise; + /** Defaults to {@link readLaunchdProgramState}. */ + readonly readProgram?: () => Promise; + /** Defaults to {@link repairLaunchdProgramIfInvalid}. */ + readonly repair?: () => Promise; + /** Defaults to reading the plist; tests pin it. */ + readonly readPlist?: () => Promise; + /** Defaults to {@link proveLaunchdJobOwnership}; tests pin it. */ + readonly proveJob?: () => Promise; + /** + * The executing CLI ({@link resolveExecutingCliPath}); null: a source + * invocation. Tests pin it. + */ + readonly executing?: string | null; + /** Defaults to {@link checkLaunchdRetargetTarget}; tests pin it. */ + readonly validateRetarget?: (path: string) => Promise; + /** + * Test seam for {@link checkLaunchdServicePath}: replaces the system + * directories of the rendered PATH. + */ + readonly systemPathDirectories?: readonly string[]; + /** Defaults to {@link readLaunchdLoadedJob}; tests pin it. */ + readonly readLoaded?: () => Promise; + /** Defaults to bootout plus bootstrap of the plist; tests pin it. */ + /** + * The uid the plist file and directory checks compare owners to; defaults + * to this process's. Tests model another account's file with another uid. + */ + readonly trustUid?: number; + readonly reloadJob?: () => Promise<{ + readonly ok: boolean; + readonly error?: string; + }>; + /** + * Called before launchd may be asked to start hackd (a reload or a + * kickstart), so the caller can tell a refusal from a failed start. + */ + readonly onStartRequested?: () => void; + /** Defaults to {@link kickstartLaunchdService}. */ + readonly kickstart?: () => Promise<{ + readonly ok: boolean; + readonly error?: string; + }>; +} + +async function readStatus( + hooks: LaunchdStartHooks +): Promise { + if (hooks.readStatus) { + return await hooks.readStatus(); + } + return process.platform === "darwin" + ? await getLaunchdServiceStatus({ paths: hooks.paths }) + : null; +} + +function readProgram(hooks: LaunchdStartHooks): Promise { + return hooks.readProgram + ? hooks.readProgram() + : readLaunchdProgramState({ + paths: hooks.paths, + executing: hooks.executing, + }); +} + +/** + * Whether this process may run under the launchd job: the job's pid is an + * ancestor of this process (a job hackd started runs `hack daemon start`), + * so restarting the job would stop the caller. Fails closed: a missing or + * implausible job pid, or an unreadable ancestry, counts as inside. Only + * called while this home's hackd runs, so a loaded job without a pid is + * not evidence that nothing runs under it. + */ +async function runsInsideJob(opts: { + readonly hooks: LaunchdStartHooks; + readonly status: LaunchdServiceStatus; +}): Promise { + const jobPid = opts.status.pid; + if (jobPid === null || !Number.isInteger(jobPid) || jobPid <= 1) { + return true; + } + const isAncestor = + opts.hooks.isAncestor ?? + ((pid: number) => hasAncestorProcess({ ancestor: pid })); + return (await isAncestor(jobPid)) !== false; +} + +/** + * Refuses when the loaded plist runs another existing executable, or one + * that cannot be read: (re)starting it would run a program this CLI does + * not own. Returns the exit code (reported) or null to go on. Used before + * `restart` stops anything and before `start` kickstarts a stopped + * service. + */ +export async function refuseForeignLaunchdProgram( + hooks: LaunchdStartHooks & { readonly verb: "restart" | "start" } +): Promise { + const status = await readStatus(hooks); + if (!status?.loaded) { + return null; + } + hooks.trace.manager = "launchd"; + const program = await readProgram(hooks); + if (program.kind !== "other" && program.kind !== "unknown") { + return null; + } + hooks.trace.blocked = + program.kind === "other" ? "other-program" : "unknown-program"; + const what = + program.kind === "other" + ? `runs ${program.program ?? "another hack"}, not this hack` + : "has a program this hack cannot read"; + logger.error({ + message: `Not ${hooks.verb === "restart" ? "restarting" : "starting"} hackd through launchd: the launchd service ${what}; nothing was changed | Run: hack daemon install`, + }); + return 1; +} + +/** + * `start` found this home's hackd running and launchd has the agent + * loaded. Returns an exit code when start is done, or null to go on as + * before (already running, or replace an incompatible daemon): + * - from inside the job nothing is rewritten or restarted: a compatible + * daemon is kept, an incompatible one refused; + * - an incompatible daemon under a plist that runs another existing or + * unreadable program is refused with `hack daemon install` (kickstart + * would run it again); + * - a compatible daemon under a stale plist is refreshed and restarted. + */ +export async function checkRunningLaunchdDaemon( + hooks: LaunchdStartHooks & { + readonly pid: number; + readonly compatible: boolean; + } +): Promise { + const status = await readStatus(hooks); + if (!status?.loaded) { + return null; + } + const { trace } = hooks; + trace.manager = "launchd"; + if (await runsInsideJob({ hooks, status })) { + trace.blocked = "inside-service"; + if (hooks.compatible) { + return null; + } + trace.stale.add("version-mismatch"); + logger.warn({ + message: `hackd (pid ${hooks.pid}) is incompatible with this CLI, but this command may run inside the launchd job (a job hackd started), so restarting it could stop this command too; nothing was changed | Run hack daemon start from a shell outside the service`, + }); + return 1; + } + if (hooks.compatible) { + return await startViaLaunchdIfLoaded({ ...hooks, onlyIfStale: true }); + } + const program = await readProgram(hooks); + if (program.kind === "other" || program.kind === "unknown") { + trace.stale.add("version-mismatch"); + trace.blocked = + program.kind === "other" ? "other-program" : "unknown-program"; + logger.warn({ + message: `hackd (pid ${hooks.pid}) is incompatible with this CLI, and the launchd service ${program.kind === "other" ? `runs ${program.program ?? "another hack"}` : "has a program this hack cannot read"}, so restarting it would not run this version | Run: hack daemon install`, + }); + return 1; + } + // The caller is about to stop this daemon and start through launchd: + // refuse now, with the daemon left running, when launchd would refuse + // the start (launchdJobBlocksReplacement). + const blocked = await launchdJobBlocksReplacement({ hooks, status }); + if (blocked !== null) { + trace.stale.add("version-mismatch"); + trace.blocked = blockedReason(blocked); + logger.warn({ + message: `hackd (pid ${hooks.pid}) is incompatible with this CLI, but ${describeReplacementBlock(blocked)}, so it was not replaced: hackd keeps running | Check: hack daemon status; then run: hack daemon install`, + }); + return 1; + } + return null; +} + +type ReplacementBlock = + | "home-binding" + | "unproven-owner" + | "other-program" + | "retarget-refused" + | "refresh-refused" + | "start-refused" + | "untrusted-plist"; + +/** The `--json` blocked reason for a {@link ReplacementBlock}. */ +function blockedReason( + block: ReplacementBlock +): + | "home-binding" + | "unproven-owner" + | "other-program" + | "rewrite-refused" + | "untrusted-unit" { + if (block === "untrusted-plist") { + return "untrusted-unit"; + } + return block === "retarget-refused" || + block === "refresh-refused" || + block === "start-refused" + ? "rewrite-refused" + : block; +} + +function describeReplacementBlock(block: ReplacementBlock): string { + switch (block) { + case "home-binding": + return "the loaded launchd job binds another Hack home"; + case "unproven-owner": + return "the loaded launchd job is not proven to be this Hack home's, or this CLI cannot be shown to be what it would run (a source checkout)"; + case "other-program": + return "the launchd job would be retargeted to a hack that is not this CLI"; + case "refresh-refused": + return "the launchd plist is stale and the program a refresh would keep, or the PATH it would give hackd, is not fit for a login service (a source file, a temporary location, or a program or PATH directory others can change)"; + case "start-refused": + return "the program the launchd service runs, or a directory of the PATH it gives hackd, is not safe from other accounts, or the configuration launchd has loaded cannot be read, so launchd would not be asked to start it"; + case "untrusted-plist": + return "the launchd plist, or a directory above it, could have been written by another account"; + default: + return "the launchd job's program is gone and the hack it would be retargeted to is not fit for a login service"; + } +} + +/** + * Whether replacing this home's running hackd through the loaded launchd + * job would be refused (HACK-2216), checked before anything is stopped, so + * `start` and `restart` refuse up front instead of leaving hackd stopped: + * - `home-binding`: the plist binds another home (or cannot be read); + * - `unproven-owner`: the job runs a process not proven to be this home's + * hackd (`pid`, a launchd-run hackd of this home, counts as proven); + * - when the plist's program is missing or virtual, the start after the + * stop would retarget it, which {@link repairLaunchdProgramIfInvalid} + * allows for a just-stopped daemon only on the same terms as a running + * one: the target must pass {@link checkLaunchdRetargetTarget} + * (`retarget-refused`) and the executing CLI must be a compiled binary + * that is that target ({@link refuseRunningRewrite}: `unproven-owner` + * from source, `other-program` for another binary). + * Null when the replacement may go on. + */ +async function launchdJobBlocksReplacement(opts: { + readonly hooks: LaunchdStartHooks; + readonly status: LaunchdServiceStatus; +}): Promise { + const { hooks, status } = opts; + // Nothing is stopped for a plist another account could have written: the + // start after the stop would refuse it before anything else. + if ( + (await checkLaunchdPlistTrusted({ + plistPath: hooks.paths.launchdPlistPath, + uid: hooks.trustUid, + })) !== null + ) { + return "untrusted-plist"; + } + const plistText = await ( + hooks.readPlist ?? (() => readTextFile(hooks.paths.launchdPlistPath)) + )(); + if (plistText === null || !plistKeepsThisHome({ plistText })) { + return "home-binding"; + } + const proven = await ( + hooks.proveJob ?? + (() => proveLaunchdJobOwnership({ paths: hooks.paths, status })) + )(); + if (!proven) { + return "unproven-owner"; + } + const program = await readProgram(hooks); + if (program.kind !== "missing") { + return await startBlocksReplacement({ hooks, plistText }); + } + if (program.resolved === null) { + return "retarget-refused"; + } + if (!(await rewriteTargetIsFit({ hooks, target: program.resolved }))) { + return "retarget-refused"; + } + return await refuseRunningRewrite({ + target: program.resolved, + executing: + hooks.executing === undefined + ? resolveExecutingCliPath() + : hooks.executing, + }); +} + +/** + * What a rewrite checks just before it writes, predicted before a running + * daemon is stopped: the program the plist will run + * ({@link checkLaunchdRetargetTarget}) and the PATH it gives hackd + * ({@link checkLaunchdServicePath}). True when both pass. + */ +async function rewriteTargetIsFit(opts: { + readonly hooks: LaunchdStartHooks; + readonly target: string; +}): Promise { + const { hooks } = opts; + const unfit = + (await (hooks.validateRetarget ?? checkLaunchdRetargetTarget)( + opts.target + )) ?? + (await checkLaunchdServicePath({ + home: resolveUserHomeDir(), + systemDirectories: hooks.systemPathDirectories, + })); + return unfit === null; +} + +/** + * The start after the stop, predicted for a plist whose program exists: + * - when it would refresh a stale plist, the program the refresh keeps and + * the PATH the refreshed plist gives hackd must pass the checks the + * refresh makes just before it writes ({@link rewriteTargetIsFit}), or + * nothing is stopped (`refresh-refused`); + * - otherwise launchd starts the plist as it is, so its program and PATH + * must pass {@link checkLaunchdJobBeforeStart}, the check before the + * kickstart, or nothing is stopped (`start-refused`). + * Null when the start may go on. + */ +async function startBlocksReplacement(opts: { + readonly hooks: LaunchdStartHooks; + readonly plistText: string; +}): Promise { + const { hooks } = opts; + const target = await readLaunchdRefreshTarget({ + paths: hooks.paths, + plistText: opts.plistText, + executing: hooks.executing, + }); + if (target !== null) { + return (await rewriteTargetIsFit({ hooks, target })) + ? null + : "refresh-refused"; + } + const unsafe = await checkLaunchdJobBeforeStart({ + plistText: opts.plistText, + systemDirectories: hooks.systemPathDirectories, + }); + if (unsafe !== null) { + return "start-refused"; + } + // The kickstart after the stop runs launchd's loaded configuration. One + // that differs is reloaded from the checked plist then (the job is no + // longer running); one that cannot be read at all is refused now, so + // hackd is not stopped for a start that may be refused. + const loaded = await (hooks.readLoaded ?? readLaunchdLoadedJob)(); + return loaded.kind === "unknown" ? "start-refused" : null; +} + +/** + * `restart` is about to stop this home's running hackd and start it again + * through the loaded launchd job (HACK-2216): refuses first, with nothing + * stopped, when that start would be refused + * ({@link launchdJobBlocksReplacement}). Exit code (reported) or null to + * go on; null too when no job is loaded. + */ +export async function refuseLaunchdBeforeReplacing( + hooks: LaunchdStartHooks +): Promise { + const status = await readStatus(hooks); + if (!status?.loaded) { + return null; + } + const blocked = await launchdJobBlocksReplacement({ hooks, status }); + if (blocked === null) { + return null; + } + hooks.trace.manager = "launchd"; + hooks.trace.blocked = blockedReason(blocked); + logger.warn({ + message: `Not restarting hackd: ${describeReplacementBlock(blocked)}, so it could not start hackd for this home again; nothing was stopped | Check: hack daemon status; then run: hack daemon install`, + }); + return 1; +} + +/** + * When launchd manages the daemon, start through launchd. Returns null when + * no launchd service is loaded, or with `onlyIfStale` when nothing was + * rewritten. See {@link repairLaunchdProgramIfInvalid} for what is + * rewritten; `kickstart -k` then (re)starts hackd. A failed rewrite is an + * error (the unload may already have stopped hackd), never "already + * running"; a stopped service whose program is another existing or + * unreadable executable is not kickstarted. + */ +export async function startViaLaunchdIfLoaded( + hooks: LaunchdStartHooks & { readonly onlyIfStale?: boolean } +): Promise { + const { paths, trace } = hooks; + // When launchd manages the daemon, start through launchd — spawning a + // bare process next to a loaded agent means two process managers fight + // over one socket and pid file. + const status = await readStatus(hooks); + if (!status?.loaded) { + return null; + } + trace.manager = "launchd"; + // A repair may reload (bootstrap) the job, which can start it: from here + // on launchd may have been asked to start hackd. + hooks.onStartRequested?.(); + // With onlyIfStale, start found this home's hackd running and compatible: + // a rewrite would unload and restart it, so the repair requires an + // eligible executing CLI (HACK-2216). + const repair = await ( + hooks.repair ?? + (() => + repairLaunchdProgramIfInvalid({ + paths, + daemonRunning: hooks.onlyIfStale === true, + systemPathDirectories: hooks.systemPathDirectories, + })) + )(); + if (repair === "failed") { + logger.error({ + message: + "The launchd service needed a rewrite that did not complete (unloading, writing or loading the plist failed, or no installed hack was found); hackd may be stopped | Run: hack daemon install, then check: hack daemon status", + }); + return 1; + } + const rewritten = noteRepair({ repair, trace }); + if (!rewritten && hooks.onlyIfStale) { + return null; + } + if (repair === "other-program" || repair === "unknown-program") { + // Before HACK-2216 start retargeted such a plist to this CLI; it no + // longer does, and kickstarting it would run a program this CLI does + // not own. + const refused = await refuseForeignLaunchdProgram({ + ...hooks, + verb: "start", + }); + if (refused !== null) { + return refused; + } + logger.error({ + message: + "Not starting hackd through launchd: the program it would run is not this CLI; nothing was changed | Run: hack daemon install", + }); + return 1; + } + if (repair === "retarget-refused") { + logger.error({ + message: + "Not starting hackd through launchd: its program is gone and the hack it would be retargeted to is not fit for a login service; nothing was changed | Install hack to a stable location, then run: hack daemon install", + }); + return 1; + } + if (repair === "untrusted-plist") { + logger.error({ + message: + "Not starting hackd through launchd: its plist, or a directory above it, could have been written by another account; nothing was changed | Make it and ~/Library/LaunchAgents writable only by you, then run: hack daemon install", + }); + return 1; + } + if (repair === "refresh-refused") { + logger.error({ + message: + "Not starting hackd through launchd: its plist is stale and the program a refresh would keep, or the PATH it would give hackd, is not fit for a login service; nothing was changed | Make the program and its PATH directories writable only by you (chmod go-w), then run: hack daemon install", + }); + return 1; + } + if (repair === "home-binding" || repair === "unproven-owner") { + // The loaded job is not provably this home's: kickstarting it would + // (re)start another home's daemon. + logger.error({ + message: + "Not starting hackd through launchd: the loaded launchd job is not proven to serve this Hack home; nothing was changed | Check: hack daemon status; to take the service over for this home, run: hack daemon install", + }); + return 1; + } + // Last, right before launchd is asked to start the job, current plist or + // just rewritten: its program and the PATH it gives hackd (HACK-2216). + const unsafe = await refuseUnsafeLaunchdStart(hooks); + if (unsafe !== null) { + return unsafe; + } + trace.action = rewritten ? "reinstalled" : "started"; + // launchd owns the daemon: never spawn a bare process next to it — + // two process managers for one socket is how machines end up with + // start-then-exit flapping and stale-pid contradictions. + const kick = await (hooks.kickstart ?? kickstartLaunchdService)(); + if (!kick.ok) { + logger.error({ + message: `launchd kickstart failed: ${kick.error ?? "unknown error"} | Check: hack daemon logs --tail 200`, + }); + return 1; + } + if (await hooks.waitForReady(8000)) { + logger.success({ message: "hackd started (launchd)" }); + return 0; + } + logger.warn({ + message: + "launchd kickstarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200", + }); + return 1; +} + +/** + * {@link checkLaunchdJobBeforeStart} on the plist launchd will start, with + * nothing started on failure: exit code 1 (reported, `blocked: + * "rewrite-refused"`, `hack daemon install` step) or null to kickstart. A + * plist that cannot be read is refused. + */ +async function refuseUnsafeLaunchdStart( + hooks: LaunchdStartHooks +): Promise { + const unsafe = await launchdStartIsUnsafe(hooks); + if (unsafe === null) { + return null; + } + hooks.trace.action = "none"; + hooks.trace.blocked = unsafe.blocked; + logger.error({ + message: `Not starting hackd through launchd: ${unsafe.reason}; nothing was started | Make the plist, the program and its PATH directories writable only by you (chmod go-w), then run: hack daemon install`, + }); + return 1; +} + +/** Why launchd must not start the job as its plist is now, or null. */ +async function launchdStartIsUnsafe( + hooks: LaunchdStartHooks +): Promise { + const plistText = await ( + hooks.readPlist ?? (() => readTextFile(hooks.paths.launchdPlistPath)) + )(); + if (plistText === null) { + return { + reason: "the launchd plist could not be read", + blocked: "rewrite-refused", + }; + } + return await verifyLaunchdJobBeforeKickstart({ + paths: hooks.paths, + plistText, + // Read fresh right before a reload: the job may have started since + // start looked. Unknown counts as running (a reload would stop hackd). + isRunning: async () => { + const status = await readStatus(hooks); + return status === null || launchdJobMayBeRunning(status); + }, + systemDirectories: hooks.systemPathDirectories, + readLoaded: hooks.readLoaded, + reload: hooks.reloadJob, + uid: hooks.trustUid, + }); +} + +/** Reports a repair result; true when the plist was rewritten. */ +function noteRepair(opts: { + readonly repair: LaunchdProgramRepairResult; + readonly trace: DaemonCommandTrace; +}): boolean { + const { repair, trace } = opts; + switch (repair) { + case "repaired": + trace.stale.add("program-missing"); + logger.warn({ + message: + "Repaired launchd service: its program no longer existed (or was a compiled-binary virtual path)", + }); + return true; + case "refreshed": + trace.stale.add("content"); + logger.warn({ + message: + "Updated launchd service: its plist differed from what this hack writes", + }); + return true; + case "other-program": + trace.blocked = "other-program"; + logger.info({ + message: + "The launchd service runs another hack; it is left as it is | To switch it to this hack, run: hack daemon install", + }); + return false; + case "unknown-program": + trace.blocked = "unknown-program"; + logger.info({ + message: + "The launchd service's program cannot be read (Program/ProgramArguments); it is left as it is | To replace it, run: hack daemon install", + }); + return false; + case "home-binding": + trace.blocked = "home-binding"; + logger.warn({ + message: + "The launchd plist binds another Hack home (HACK_HOME or HOME in EnvironmentVariables), so it is not rewritten from here | To take the service over for this home, run: hack daemon install", + }); + return false; + case "unproven-owner": + trace.blocked = "unproven-owner"; + logger.warn({ + message: + "The launchd job is not rewritten or restarted from here: it is not proven to be this Hack home's, or hackd runs and this CLI cannot be shown to be what it would run (a source checkout) | Check: hack daemon status; then run: hack daemon install", + }); + return false; + case "retarget-refused": + trace.stale.add("program-missing"); + trace.blocked = "rewrite-refused"; + logger.warn({ + message: + "The launchd service's program is gone, and the hack it would be retargeted to is not fit for a login service (a source file, a bun script, a temporary location or one others can change) | Install hack to a stable location, then run: hack daemon install", + }); + return false; + case "refresh-refused": + trace.stale.add("content"); + trace.blocked = "rewrite-refused"; + logger.warn({ + message: + "The launchd plist is stale, but the program a refresh would keep, or the PATH it would give hackd, is not fit for a login service (a source file, a bun script, a temporary location, or a program or PATH directory others can change), so it was not rewritten or restarted | Fix that, then run: hack daemon install", + }); + return false; + case "untrusted-plist": + trace.blocked = "untrusted-unit"; + logger.warn({ + message: + "The launchd plist, or a directory above it, could have been written by another account, so it was not rewritten, unloaded or loaded | Make it and ~/Library/LaunchAgents writable only by you, then run: hack daemon install", + }); + return false; + case "unmanaged": + trace.stale.add("content"); + trace.blocked = "unmanaged"; + logger.info({ + message: + "The launchd plist differs from what this hack writes, but it is not hack's (no managed marker and another Label), so it is left as it is | To replace it, run: hack daemon install", + }); + return false; + default: + return false; + } +} diff --git a/src/commands/daemon-replace.ts b/src/commands/daemon-replace.ts new file mode 100644 index 000000000..307ca0aa0 --- /dev/null +++ b/src/commands/daemon-replace.ts @@ -0,0 +1,161 @@ +import type { DaemonCommandTrace } from "../daemon/reconcile.ts"; +import { logger } from "../ui/logger.ts"; + +/** + * How `start` tried to start hackd once none of this home's ran, reported + * by the start itself (never inferred from the `--json` trace): + * - `service-refused`: the service path refused before asking the manager + * to start anything (an untrusted unit, an unreadable home, a refused + * rewrite...); + * - `service-attempted`: the manager was asked to start (or load) hackd; + * - `direct-attempted`: hackd was started directly, or another starter + * owns the start (no service involved, nothing to fall back to). + * `manager` names the service involved, null when none was. + */ +export interface ReplacementStartOutcome { + readonly kind: "service-refused" | "service-attempted" | "direct-attempted"; + readonly exitCode: number; + readonly manager: "systemd" | "launchd" | null; +} + +/** + * Whether the service manager will not start hackd on its own any more: + * `idle` only on positive evidence (nothing queued, no restart or KeepAlive + * retry pending); `pending` when it may still start it; `unknown` when it + * cannot be told. Only `idle` allows a direct start next to it. + */ +export type ServiceManagerState = "idle" | "pending" | "unknown"; + +/** + * `start` has stopped this home's incompatible, running hackd to replace + * it (HACK-2216). Starts the replacement through the service manager, and + * when that is refused, fails, or throws after the stop, starts this home's + * hackd directly instead (the detached start used when the unit serves + * another home), so a replacement does not leave hackd stopped. + * + * The direct start runs only when all of these hold: + * - the service path was involved (`outcome.kind` is not + * `direct-attempted`: a direct start, or another starter, already ran); + * - no hackd of this home answers now; + * - the manager is positively idle ({@link ServiceManagerState}): otherwise + * a pending start, restart timer or KeepAlive retry could start a second + * daemon next to the direct one. Then the service error stands with + * `blocked: "service-pending"`. + * The daemon was running before the stop, so this is allowed in the + * post-update reconcile-only mode too. + * + * Success reports action `restarted` with `fallback: "direct"` and keeps + * the service path's `blocked` reason. If the direct start also fails, the + * service's result stands: its exit code, or its error rethrown. + * + * A start that reports success only counts when `verifyStarted` confirms + * the replacement answers on this home's socket and reports this CLI's + * version: a pid and a socket are not enough, because an older `hack` + * could have been started (HACK-2216). Otherwise the replacement failed: + * exit 1, action `none`, never `restarted`. + */ +export async function startReplacementDaemon(opts: { + readonly trace: DaemonCommandTrace; + readonly startViaService: () => Promise; + readonly startDirect: () => Promise; + /** Whether this home's hackd runs (pid alive and socket present) now. */ + readonly isRunning: () => Promise; + /** The manager's state; `manager` null: the platform's own manager. */ + readonly managerState: ( + manager: "systemd" | "launchd" | null + ) => Promise; + /** + * Null when the started hackd answers on this home's socket and reports + * this CLI's version; otherwise what it reports instead. + */ + readonly verifyStarted: () => Promise; +}): Promise { + const { trace } = opts; + let outcome: ReplacementStartOutcome; + let thrown: { readonly error: unknown } | null = null; + try { + outcome = await opts.startViaService(); + } catch (error: unknown) { + // A rejection after the stop is a failed service start, not a reason + // to leave hackd stopped; which manager got how far is unknown. + thrown = { error }; + outcome = { kind: "service-attempted", exitCode: 1, manager: null }; + } + if (outcome.exitCode === 0) { + if (await refuseUnverified({ trace, verify: opts.verifyStarted })) { + return 1; + } + if (trace.action === "started") { + trace.action = "restarted"; + } + return 0; + } + const fail = (): number => { + if (thrown) { + throw thrown.error; + } + return outcome.exitCode; + }; + if (outcome.kind === "direct-attempted" || (await opts.isRunning())) { + return fail(); + } + const state = await opts.managerState(outcome.manager); + const manager = outcome.manager ?? "service"; + if (state !== "idle") { + trace.blocked = "service-pending"; + logger.warn({ + message: `The ${manager} service could not start the replacement hackd, and it ${state === "pending" ? "may still start it (a queued start, restart or retry is pending)" : "could not be checked for a pending start"}, so hackd was not started directly next to it | Check: hack daemon status in a moment; if hackd is still stopped, run: hack daemon install`, + }); + return fail(); + } + logger.warn({ + message: `The ${manager} service could not start the replacement hackd; starting it directly so it keeps running (it stops at logout or reboot) | Run: hack daemon install`, + }); + const direct = await opts.startDirect(); + if (direct !== 0) { + return fail(); + } + if (await refuseUnverified({ trace, verify: opts.verifyStarted })) { + return 1; + } + trace.action = "restarted"; + trace.fallback = "direct"; + return 0; +} + +/** + * What `verifyStarted` reports for a probe of this home's socket: null when + * the daemon answers and is compatible with this CLI (reports its + * version), otherwise what it does instead. + */ +export function describeUnverifiedReplacement(api: { + readonly reachable: boolean; + readonly compatible: boolean; + readonly version: string | null; +}): string | null { + if (!api.reachable) { + return "it does not answer on this home's socket"; + } + return api.compatible + ? null + : `it reports version ${api.version ?? "unknown"}, not this CLI's`; +} + +/** + * True (reported, action `none`) when the replacement that started is not + * this CLI's hackd on this home's socket. + */ +async function refuseUnverified(opts: { + readonly trace: DaemonCommandTrace; + readonly verify: () => Promise; +}): Promise { + const problem = await opts.verify(); + if (problem === null) { + return false; + } + opts.trace.action = "none"; + logger.error({ + message: `hackd was replaced, but ${problem}, so it is not running this version | Run: hack daemon install, then check: hack daemon status`, + }); + return true; +} diff --git a/src/commands/daemon-result.ts b/src/commands/daemon-result.ts new file mode 100644 index 000000000..14e34a04d --- /dev/null +++ b/src/commands/daemon-result.ts @@ -0,0 +1,83 @@ +import { DAEMON_CLI_VERSION, probeDaemonApi } from "../daemon/client.ts"; +import type { DaemonPaths } from "../daemon/paths.ts"; +import { + buildDaemonCommandData, + createDaemonCommandTrace, + type DaemonCommandName, + type DaemonCommandTrace, + type DaemonObservation, +} from "../daemon/reconcile.ts"; +import { readDaemonStatus } from "../daemon/status.ts"; +import { emitCliResult, errorResult, okResult } from "../lib/cli-result.ts"; +import { + captureLoggedProblems, + setLoggerBackendOverride, +} from "../ui/logger.ts"; + +/** + * Runs one `hack daemon start|stop|restart|install` step and, with + * `--json`, prints its single envelope (HACK-2216, HACK-2238): + * `{ok: true, data: {schema: "hack.daemon-.v1", ...}}`, or on a + * nonzero exit `{ok: false, error: {code: "E_DAEMON_SERVICE", message, + * detail}}` whose message is the last warning or error the step logged and + * whose detail is the same data. Human lines go to stderr meanwhile, so + * stdout holds only the envelope. Exit codes are unchanged. A thrown error + * (for example an access refusal) propagates to the CLI's own `--json` + * error handling. + */ +export async function runDaemonCommand(opts: { + readonly command: DaemonCommandName; + readonly json: boolean; + readonly paths: DaemonPaths; + readonly run: (trace: DaemonCommandTrace) => Promise; +}): Promise { + const trace = createDaemonCommandTrace(); + if (!opts.json) { + return await opts.run(trace); + } + setLoggerBackendOverride({ backend: "console" }); + const problems = captureLoggedProblems(); + let code: number; + try { + code = await opts.run(trace); + } finally { + problems.stop(); + setLoggerBackendOverride({ backend: null }); + } + const data = buildDaemonCommandData({ + command: opts.command, + trace, + daemon: await observeDaemon({ paths: opts.paths }), + cliVersion: DAEMON_CLI_VERSION, + }); + emitCliResult({ + result: + code === 0 + ? okResult({ data }) + : errorResult({ + code: "E_DAEMON_SERVICE", + message: problems.last() ?? `hack daemon ${opts.command} failed`, + detail: data, + }), + }); + return code; +} + +/** The daemon after the command: its pid file and what its API reports. */ +async function observeDaemon(opts: { + readonly paths: DaemonPaths; +}): Promise { + const status = await readDaemonStatus({ paths: opts.paths }); + if (!(status.running && status.socketExists)) { + return { running: status.running, pid: status.pid, version: null }; + } + const probe = await probeDaemonApi({ + socketPath: opts.paths.socketPath, + timeoutMs: 1000, + }); + return { + running: true, + pid: status.pid, + version: probe.reachable ? probe.version : null, + }; +} diff --git a/src/commands/daemon-systemd.ts b/src/commands/daemon-systemd.ts index b0d501baf..d5b188f25 100644 --- a/src/commands/daemon-systemd.ts +++ b/src/commands/daemon-systemd.ts @@ -2,6 +2,9 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { GLOBAL_DAEMON_DIR_NAME } from "../constants.ts"; +import { DAEMON_CLI_VERSION } from "../daemon/client.ts"; +import { resolveExecutingCliPath } from "../daemon/executing-cli.ts"; +import { isSameExecutableFile } from "../daemon/launchd.ts"; import type { DaemonPaths } from "../daemon/paths.ts"; import { type DaemonProcessProbe, @@ -12,6 +15,13 @@ import { proveOwnedDaemonProcess, } from "../daemon/process.ts"; import { readLinuxParentPid } from "../daemon/process-image.ts"; +import { + type DaemonCommandTrace, + type DaemonReconcilePlan, + planDaemonReconcile, + recordReconcile, + type ServiceUnitState, +} from "../daemon/reconcile.ts"; import { type DaemonServiceSummary, summarizeSystemdService, @@ -21,23 +31,31 @@ import { assessExecutableDurability, checkServiceExecutableTrusted, checkServicePathDirectories, + checkSystemdEffectivePath, + checkSystemdEffectiveService, checkSystemdLoadedUnitPaths, checkSystemdStopHooks, checkSystemdUnitTrusted, + checkSystemdUnitTrustedBeforeChange, defaultSystemdDeps, describeSystemdUnavailable, extractSystemdExecProgram, + findDroppedHomeBinding, installSystemdUnit, + isHackManagedUnit, isPidInSystemdUnit, isSystemdReadOnly, LINGER_ENABLE_COMMAND, MANAGER_STOPPABLE_STATES, probeSystemdUserManager, RENDERED_SYSTEM_PATH_DIRECTORIES, + readInvokerInSystemdUnit, readLingerStatus, + readSystemdLoadedProgram, readSystemdServiceStatus, readSystemdUnitHackHome, renderedServicePathDirectories, + renderSystemdUnit, resolveExpectedSystemdUnit, resolveSystemdAccess, resolveSystemdUnitPath, @@ -45,6 +63,7 @@ import { SYSTEMD_UNIT_NAME, type SystemdDeps, type SystemdServiceStatus, + type SystemdUnitDrift, systemdDaemonReload, systemdServiceAction, uninstallSystemdUnit, @@ -64,6 +83,8 @@ import { logger } from "../ui/logger.ts"; const RESTART_PENDING_FILE = "hackd.service.restart-pending"; const JOURNAL_HINT = `journalctl --user -u ${SYSTEMD_UNIT_NAME} -n 50`; const READY_TIMEOUT_MS = 8000; +/** The stop refusals' own lead-in, dropped when a restart reuses them. */ +const STOP_REFUSAL_PREFIX = /^Not stopping hackd\.service: /; export interface SystemdCommandHooks { readonly paths: DaemonPaths; @@ -93,6 +114,139 @@ export interface SystemdCommandHooks { * directories so they do not depend on the host's. */ readonly systemPathDirectories?: readonly string[]; + /** + * Proves the unit's main process is a hackd holding a socket in + * `daemonRoot` before an automatic restart; defaults to + * {@link proveOwnedDaemonProcess}. Tests pin it. + */ + readonly proveMainProcess?: ( + pid: number, + daemonRoot: string + ) => Promise; + /** Test seam for the default main-process proof's observations. */ + readonly probe?: DaemonProcessProbe; + /** Test seam: a process's parent pid (defaults to /proc). */ + readonly parentPid?: (pid: number) => Promise; + /** + * Called just before systemd is asked to start the unit, so the caller + * can tell a refusal (nothing asked) from a failed start. + */ + readonly onStartRequested?: () => void; + /** + * The executing CLI's executable ({@link resolveExecutingCliPath}): what + * "this CLI" means for automatic restart and rewrite eligibility. Tests + * pin it; null is a source invocation (never eligible). + */ + readonly executingProgram?: () => Promise; + /** Defaults to reading `/proc/self/cgroup`; tests pin it. */ + readonly readSelfCgroup?: () => Promise; + /** Records what the command decided, for `--json` (HACK-2216). */ + readonly trace?: DaemonCommandTrace; + /** Defaults to this CLI's version; tests pin it. */ + readonly cliVersion?: string; + /** + * The uid the unit trust checks compare owners to; defaults to this + * process's. Tests model a file another account owns with another uid. + */ + readonly trustUid?: number; +} + +/** + * The last check before an automatic restart (HACK-2216): the ownership + * proof and loaded-program read before it take time, so the program is + * checked again ({@link checkServiceExecutableTrusted}) and a pathname + * replaced meanwhile is not what the restart starts. Both the main file's + * program and the exact pathname systemd will run are checked: the single + * loaded `ExecStart` ({@link readSystemdLoadedProgram}), which a retained + * drop-in can point at another path to the same file, with its symlinks + * and every directory above it. The effective service PATH + * ({@link checkSystemdEffectivePath}) is checked again too. Exit code 1 + * (reported) or null to restart: `blocked: "unproven-owner"` when the + * loaded program cannot be read or is not exactly one entry, + * `rewrite-refused` when a program or PATH directory fails the checks. + * The stopped start's last check before `systemctl start` is + * {@link checkSystemdLoadedUnitPaths}, which covers the same program and + * PATH checks. + */ +async function refuseUntrustedProgramLast(opts: { + readonly hooks: Pick; + readonly unitPath: string; + readonly deps: SystemdDeps; +}): Promise { + const loaded = await readSystemdLoadedProgram({ deps: opts.deps }); + if (loaded.kind === "unknown") { + return refuseLastCheck({ + hooks: opts.hooks, + blocked: "unproven-owner", + message: `Not restarting ${SYSTEMD_UNIT_NAME}: the program systemd would run could not be read again just before the restart (${loaded.reason})`, + }); + } + // The effective PATH too: hackd runs helpers such as `docker` by bare + // name, and a PATH directory may have become writable meanwhile. + const untrusted = + (await checkServiceExecutableTrusted({ path: loaded.path })) ?? + (await checkUnitProgramTrusted({ unitPath: opts.unitPath })) ?? + (await checkSystemdEffectivePath({ deps: opts.deps })); + return untrusted === null + ? null + : refuseLastCheck({ + hooks: opts.hooks, + blocked: "rewrite-refused", + message: untrusted, + }); +} + +/** Records and reports a refusal of {@link refuseUntrustedProgramLast}. */ +function refuseLastCheck(opts: { + readonly hooks: Pick; + readonly blocked: "unproven-owner" | "rewrite-refused"; + readonly message: string; +}): number { + if (opts.hooks.trace) { + opts.hooks.trace.action = "none"; + opts.hooks.trace.blocked = opts.blocked; + } + logger.error({ message: withInstallStep(opts.message) }); + return 1; +} + +/** A refusal that ends with the `hack daemon install` step. */ +function withInstallStep(message: string): string { + return message.includes("hack daemon install") + ? message + : `${message} | Run: hack daemon install`; +} + +/** + * Records an untrusted-unit refusal for `--json`: the systemd service was + * found, nothing changed, `blocked: "untrusted-unit"`. + */ +function recordUntrustedUnit(trace: DaemonCommandTrace | undefined): void { + if (trace) { + trace.manager = "systemd"; + trace.action = "none"; + trace.blocked = "untrusted-unit"; + } +} + +/** + * {@link checkSystemdUnitTrustedBeforeChange} for a command: null, or the + * refusal after recording `blocked: "untrusted-unit"` (nothing changed). + */ +async function refuseUntrustedUnit(opts: { + readonly hooks: Pick; + readonly unitPath: string; + readonly deps: SystemdDeps; +}): Promise { + const untrusted = await checkSystemdUnitTrustedBeforeChange({ + unitPath: opts.unitPath, + deps: opts.deps, + uid: opts.hooks.trustUid, + }); + if (untrusted !== null) { + recordUntrustedUnit(opts.hooks.trace); + } + return untrusted; } /** Status for `daemon status` and doctor; null off Linux or when access is denied. */ @@ -148,6 +302,128 @@ export async function resolveSystemdSummary(opts: { * directly. See {@link prepareSystemdUnitForStart} for what start may change * in the unit (almost nothing). */ +/** + * Before `start` stops this home's incompatible, directly started hackd to + * replace it (HACK-2216): whether the start that follows would be refused + * by {@link startViaSystemdIfInstalled}, so the refusal comes first and the + * running daemon is left alone. Checks what refuses there regardless of + * the daemon: an untrusted unit, a unit whose Hack home cannot be read, a + * missing program that read-only mode may not repair, and loaded files or + * a program Hack cannot vouch for. Null when the start may go on (or does + * not go through systemd at all, which starts hackd directly). + */ +export async function refuseSystemdStartBeforeReplacing( + hooks: Pick< + SystemdCommandHooks, + | "paths" + | "deps" + | "resolveExpected" + | "assessDurability" + | "systemPathDirectories" + | "cliVersion" + | "trustUid" + > +): Promise<{ + readonly message: string; + readonly blocked: + | "unproven-owner" + | "read-only" + | "rewrite-refused" + | "untrusted-unit"; +} | null> { + const deps = hooks.deps ?? defaultSystemdDeps(); + if (!resolveSystemdAccess({ env: deps.env }).allowed) { + return null; + } + const unitPath = resolveSystemdUnitPath({ env: deps.env }); + if (!(await pathExists(unitPath))) { + return null; + } + const status = await readSystemdServiceStatus({ unitPath, deps }); + if (!status?.availability.available) { + return null; + } + const untrusted = await checkSystemdUnitTrusted({ + unitPath, + uid: hooks.trustUid, + }); + if (untrusted !== null) { + return { message: withInstallStep(untrusted), blocked: "untrusted-unit" }; + } + const unitHome = await checkUnitServesHome({ + hackHome: dirname(hooks.paths.root), + deps, + action: "starting", + }); + if (unitHome.kind === "foreign") { + // Start leaves that unit alone and starts this home's hackd directly. + return null; + } + if (unitHome.kind === "unknown") { + return { message: unitHome.message, blocked: "unproven-owner" }; + } + // What the start would check before it reads the unit to rewrite or + // retarget it, or reloads it (HACK-2216): never stop the running daemon + // for a unit another account could have written. + const beforeChange = await checkSystemdUnitTrustedBeforeChange({ + unitPath, + deps, + uid: hooks.trustUid, + }); + if (beforeChange !== null) { + return { message: beforeChange, blocked: "untrusted-unit" }; + } + // The same plan and rewrite computation the start itself runs after the + // stop (planStoppedStart, computeUnitRewrite), without writing anything. + const { plan, expected } = await planStoppedStart({ + status, + unitPath, + deps, + hooks, + }); + if (plan.service === "refuse") { + return { + message: `${describeMissingProgram(status)}; not changing the service here | Run: hack daemon install`, + blocked: "read-only", + }; + } + if (plan.rewrite !== "none") { + const rewrite = await computeUnitRewrite({ + plan, + status, + unitPath, + deps, + hooks, + expected, + }); + if ("error" in rewrite) { + return { message: rewrite.error, blocked: "rewrite-refused" }; + } + // The rewrite replaces only the main file: drop-ins survive it, and + // the start checks the effective configuration (drop-ins included) + // after writing. Check what survives now, as that start will. This + // reads the configuration systemd has loaded, so an unsafe setting the + // rewrite itself would remove can also refuse here: that only keeps + // the running daemon, and `hack daemon install` resolves it. A program + // retarget replaces the (missing) program the loaded copy names, so + // only the effective PATH is checked then. + const retained = + plan.rewrite === "program" + ? await checkSystemdEffectivePath({ deps }) + : ((await checkSystemdLoadedUnitPaths({ unitPath, deps })) ?? + (await checkSystemdEffectiveService({ deps }))); + return retained === null + ? null + : { message: retained, blocked: "rewrite-refused" }; + } + const loaded = + (await checkSystemdLoadedUnitPaths({ unitPath, deps })) ?? + (await checkUnitProgramTrusted({ unitPath })); + return loaded === null + ? null + : { message: loaded, blocked: "unproven-owner" }; +} + export async function startViaSystemdIfInstalled( hooks: Omit ): Promise { @@ -173,9 +449,13 @@ export async function startViaSystemdIfInstalled( return null; } // Never start a unit (or drop-in) another account could have written. - const untrusted = await checkSystemdUnitTrusted({ unitPath }); + const untrusted = await checkSystemdUnitTrusted({ + unitPath, + uid: hooks.trustUid, + }); if (untrusted !== null) { - logger.error({ message: untrusted }); + recordUntrustedUnit(hooks.trace); + logger.error({ message: withInstallStep(untrusted) }); return 1; } const hackHome = dirname(hooks.paths.root); @@ -211,6 +491,7 @@ export async function startViaSystemdIfInstalled( logger.error({ message: loaded }); return 1; } + hooks.onStartRequested?.(); const started = await systemdServiceAction({ action: "start", deps }); if (!started.ok) { logger.error({ @@ -228,6 +509,428 @@ export async function startViaSystemdIfInstalled( return 1; } +/** + * `hack daemon start` found this home's hackd running (HACK-2216). When + * hackd.service runs it, brings the service up to this CLI with + * {@link planDaemonReconcile}: rewrite a stale unit (as + * {@link prepareSystemdUnitForStart} does) and `systemctl --user restart` it + * when the unit changed or the daemon reports another version. A version + * mismatch that a restart cannot fix (the unit runs another existing + * `hack`, or an ExecStart hack does not read) is refused with the + * `hack daemon install` step. + * + * Returns null when this does not apply (no usable unit, systemd does not + * run `pid`, the unit serves another Hack home) or nothing needs to change; + * the caller then goes on as before. Otherwise the exit code. + */ +export async function reconcileRunningSystemdDaemon( + hooks: Omit & { + readonly pid: number; + /** What the daemon's API reported; null when it did not answer. */ + readonly version: string | null; + } +): Promise { + const deps = hooks.deps ?? defaultSystemdDeps(); + if (!resolveSystemdAccess({ env: deps.env }).allowed) { + return null; + } + const unitPath = resolveSystemdUnitPath({ env: deps.env }); + if (!(await pathExists(unitPath))) { + return null; + } + const status = await readSystemdServiceStatus({ unitPath, deps }); + if ( + !status?.availability.available || + status.stateError !== null || + !(await systemdRunsPid({ + status, + pid: hooks.pid, + isPidInUnit: hooks.isPidInUnit, + })) + ) { + return null; + } + const unitHome = await checkUnitServesHome({ + hackHome: dirname(hooks.paths.root), + deps, + action: "starting", + }); + if (unitHome.kind !== "ours") { + return null; + } + const expected = await ( + hooks.resolveExpected ?? resolveExpectedSystemdUnit + )(); + const marker = resolve(hooks.paths.root, RESTART_PENDING_FILE); + // A job hackd started (a supervisor job running `hack daemon start`) + // runs in the unit's cgroup: restarting the unit would stop the caller. + // Unknown counts as inside. + const invoker = await readInvokerInSystemdUnit({ + deps, + readSelfCgroup: hooks.readSelfCgroup, + }); + // A restart runs what systemd has loaded, drop-ins included, not only + // the main file: both must be this CLI's executable. While a reload is + // pending for a unit file that runs this CLI (an earlier repair wrote it + // but its daemon-reload failed), what is loaded is stale by definition: + // the restart path reloads first, and the check just before the restart + // (refuseUnexpectedLoadedProgram) judges the newly loaded program. + // "This CLI" is the executing binary, not the PATH-selected `hack` + // (`expected`, the install and retarget target). + const executing = await resolveExecutingProgram(hooks); + const fileIsOurs = await unitRunsExpected({ status, executing }); + const reloadPending = + status.needsReload && !isSystemdReadOnly(deps.env) && fileIsOurs; + const loaded = reloadPending + ? ("ours" as const) + : await loadedProgramIsExpected({ deps, executing }); + const planned = planDaemonReconcile({ + unit: serviceUnitState(status.drift), + programIsOurs: loaded === "ours" && fileIsOurs, + readOnly: isSystemdReadOnly(deps.env), + running: { version: hooks.version }, + restartPending: await pathExists(marker), + contentBlock: await readContentBlock({ status, unitPath }), + invokerInsideService: invoker !== "outside", + cliVersion: hooks.cliVersion ?? DAEMON_CLI_VERSION, + }); + const plan = gateRestartOnLoadedProgram({ + plan: + loaded === "unknown" && planned.blocked !== null + ? { ...planned, blocked: "unproven-owner" as const } + : planned, + loaded, + }); + recordReconcile({ trace: hooks.trace, manager: "systemd", plan }); + if (plan.service === "keep") { + noteKeptStaleUnit({ plan }); + if (plan.blocked === "read-only") { + logger.warn({ + message: `${SYSTEMD_UNIT_NAME} has changes that are not loaded yet (a pending reload or restart); not reloading or restarting it here, and hackd already runs this version | Run: hack daemon install (or hack daemon start outside hack doctor --fix)`, + }); + } + return null; + } + if (plan.service === "refuse") { + logger.warn({ + message: describeRunningRefusal({ + plan, + status, + expected, + pid: hooks.pid, + }), + }); + return 1; + } + // Trust comes first in every changing path (HACK-2216): before the unit + // is read to keep its directives, rewritten, retargeted or reloaded, and + // before the restart. A rewrite replaces the file as this user, so the + // check after it would pass for directives another account planted. + const untrusted = await refuseUntrustedUnit({ hooks, unitPath, deps }); + if (untrusted !== null) { + logger.error({ message: untrusted }); + return 1; + } + // A restart stops the unit's main process and everything in its cgroup: + // prove first, as `stop` does, that it is this home's hackd (HACK-2216). + const unproven = await refuseUnprovenRestart({ hooks, status, deps }); + if (unproven !== null) { + return unproven; + } + return await restartReconciledUnit({ + hooks, + plan, + status, + unitPath, + deps, + marker, + homePid: hooks.pid, + }); +} + +/** + * Finishes a daemon-reload that an earlier write left pending + * (`NeedDaemonReload=yes`) before an automatic restart, then checks again + * which home the reloaded unit serves. The restart-pending marker is + * written (or kept) so a failure here is retried as a restart. Under + * {@link isSystemdReadOnly} nothing is reloaded ("read-only"): the restart + * goes on with the loaded copy and the marker stays. + */ +async function completeReloadBeforeRestart(opts: { + readonly hooks: Omit; + readonly deps: SystemdDeps; +}): Promise<"reloaded" | "read-only" | "failed"> { + const { hooks, deps } = opts; + await writeRestartMarker({ paths: hooks.paths }); + if (isSystemdReadOnly(deps.env)) { + logger.warn({ + message: `systemd has an older copy of ${SYSTEMD_UNIT_NAME} loaded; not reloading it here, so the restart uses that copy | Run: hack daemon install`, + }); + return "read-only"; + } + const reload = await systemdDaemonReload({ deps }); + if (!reload.ok) { + logger.error({ + message: `systemctl --user daemon-reload failed: ${reload.error}; ${SYSTEMD_UNIT_NAME} was not restarted (a later hack daemon start retries) | Check: ${JOURNAL_HINT}`, + }); + return "failed"; + } + const unitHome = await checkUnitServesHome({ + hackHome: dirname(hooks.paths.root), + deps, + action: "starting", + }); + if (unitHome.kind !== "ours") { + logger.error({ + message: `${unitHome.message}; not restarting it after the reload | Run: hack daemon install`, + }); + return "failed"; + } + return "reloaded"; +} + +/** + * The automatic restart stops the unit's main process and its cgroup, so + * it needs the same proof as `stop` ({@link checkMainProcessServesHome}, + * {@link decideUnitStop}): the live main process is this home's hackd (or + * none runs and the configuration names this home). Cgroup membership of + * this home's pid alone is not enough: this home's hackd may have been + * started by a job of another home's daemon, or a reload may have pointed + * the configuration at this home while the running daemon serves another. + * Returns 1 (reported, nothing changed, `blocked: "unproven-owner"`) or + * null to go on. + */ +async function refuseUnprovenRestart(opts: { + readonly hooks: Omit & { + readonly pid: number; + }; + readonly status: SystemdServiceStatus; + readonly deps: SystemdDeps; +}): Promise { + const { hooks, status, deps } = opts; + const decided = await decideUnitStop({ + mainProcess: await checkMainProcessServesHome({ + status, + daemonRoot: hooks.paths.root, + prove: hooks.proveMainProcess, + probe: hooks.probe, + homePid: hooks.pid, + isPidInUnit: hooks.isPidInUnit, + parentPid: hooks.parentPid, + }), + unitHome: () => + checkUnitServesHome({ + hackHome: dirname(hooks.paths.root), + deps, + action: "stopping", + }), + }); + if (decided.kind === "stop") { + return null; + } + if (hooks.trace) { + hooks.trace.action = "none"; + hooks.trace.blocked = "unproven-owner"; + } + logger.warn({ + message: `Not restarting ${SYSTEMD_UNIT_NAME} to update it: ${decided.message.replace(STOP_REFUSAL_PREFIX, "")}`, + }); + return 1; +} + +/** + * The reload may have changed what the unit is configured for: prove + * ownership again just before the restart lands, from a fresh state read + * only. A failed read reports no main process, which is not evidence that + * none runs, so it is refused ({@link refuseUnobservedRestart}), never + * replaced by an earlier snapshot. Returns 1 (reported) or null. + */ +async function proveOwnershipBeforeRestart(opts: { + readonly hooks: Omit; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly homePid: number; +}): Promise { + const { hooks, deps } = opts; + const fresh = await readSystemdServiceStatus({ + unitPath: opts.unitPath, + deps, + }); + if (fresh === null) { + return refuseUnobservedRestart({ + hooks, + detail: "systemd could not be asked", + }); + } + if (!fresh.availability.available) { + return refuseUnobservedRestart({ + hooks, + detail: describeSystemdUnavailable(fresh.availability), + }); + } + if (fresh.stateError !== null) { + return refuseUnobservedRestart({ hooks, detail: fresh.stateError }); + } + return await refuseUnprovenRestart({ + hooks: { ...hooks, pid: opts.homePid }, + status: fresh, + deps, + }); +} + +/** + * The final ownership proof could not observe the unit's state: refused + * like an unproven owner (`blocked: "unproven-owner"`, no restart). + */ +function refuseUnobservedRestart(opts: { + readonly hooks: Pick; + readonly detail: string; +}): number { + if (opts.hooks.trace) { + opts.hooks.trace.action = "none"; + opts.hooks.trace.blocked = "unproven-owner"; + } + logger.warn({ + message: `Not restarting ${SYSTEMD_UNIT_NAME} to update it: its state could not be read again just before the restart (${opts.detail}), so whose daemon it runs cannot be proven | Run hack daemon start again; check: systemctl --user status ${SYSTEMD_UNIT_NAME}`, + }); + return 1; +} + +/** The refusal for a running hackd whose version a restart cannot fix. */ +function describeRunningRefusal(opts: { + readonly plan: DaemonReconcilePlan; + readonly status: SystemdServiceStatus; + readonly expected: { readonly hackBinPath: string } | null; + readonly pid: number; +}): string { + const { plan, status, expected } = opts; + if (plan.blocked === "inside-service") { + return `hackd (pid ${opts.pid}) is incompatible with this CLI, but this command runs inside ${SYSTEMD_UNIT_NAME} (a job hackd started), so restarting it would stop this command too; nothing was changed | Run hack daemon start from a shell outside the service`; + } + if (plan.blocked === "read-only") { + return `${describeMissingProgram(status)}; not changing the service here | Run: hack daemon install`; + } + if (plan.blocked === "unproven-owner") { + return `hackd (pid ${opts.pid}) is incompatible with this CLI, but the program systemd has loaded for ${SYSTEMD_UNIT_NAME} cannot be read, so a restart is not known to run this version | Check: systemctl --user cat ${SYSTEMD_UNIT_NAME}; then run: hack daemon install`; + } + if (plan.blocked === "unknown-program" || status.program === null) { + return `hackd (pid ${opts.pid}) is incompatible with this CLI, and ${SYSTEMD_UNIT_NAME} has an ExecStart hack does not read, so a restart is not known to run this version | Check: systemctl --user cat ${SYSTEMD_UNIT_NAME}; to switch it to this hack, run: hack daemon install`; + } + const target = expected + ? ` (switches the service to ${expected.hackBinPath})` + : ""; + return `hackd (pid ${opts.pid}) is incompatible with this CLI, and ${SYSTEMD_UNIT_NAME} runs ${status.program}, so restarting it would start the same version again | Run: hack daemon install${target}`; +} + +/** + * Rewrites the unit when the plan says so, then restarts it through + * systemd. A restart runs the unit's stop hooks and stops what hackd + * started, so the same checks as `stop` and `start` run first. The + * restart-pending marker (shared with install) is written before the unit + * changes and removed only after the restart, so a failure in between is + * retried as a restart. + */ +async function restartReconciledUnit(opts: { + readonly hooks: Omit; + readonly plan: DaemonReconcilePlan; + readonly status: SystemdServiceStatus; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly marker: string; + /** This home's hackd per its pid file. */ + readonly homePid: number; +}): Promise { + const { hooks, plan, status, unitPath, deps } = opts; + let reloadStillPending = false; + if (plan.rewrite === "none" && status.needsReload) { + // An earlier repair wrote the unit but its daemon-reload failed: the + // file is current, systemd's loaded copy is not. Restarting now would + // run the old configuration and clear the marker, so finish the reload + // first (keeping the marker until the restart completes). + const reloaded = await completeReloadBeforeRestart({ hooks, deps }); + if (reloaded === "failed") { + return 1; + } + reloadStillPending = reloaded === "read-only"; + } + if (plan.rewrite !== "none") { + await writeRestartMarker({ paths: hooks.paths }); + const rewritten = await rewriteUnitForPlan({ + plan, + status, + unitPath, + deps, + hooks, + expected: await (hooks.resolveExpected ?? resolveExpectedSystemdUnit)(), + }); + if (rewritten !== null) { + return rewritten; + } + // The rewrite reloaded systemd: check again which home the unit now + // serves before restarting it, as start does after its own repair. + const unitHome = await checkUnitServesHome({ + hackHome: dirname(hooks.paths.root), + deps, + action: "starting", + }); + if (unitHome.kind !== "ours") { + logger.error({ + message: `${unitHome.message}; not restarting it after the rewrite | Run: hack daemon install`, + }); + return 1; + } + } + const blocked = + (await checkSystemdUnitTrusted({ unitPath, uid: hooks.trustUid })) ?? + (await checkSystemdLoadedUnitPaths({ unitPath, deps })) ?? + (await checkUnitProgramTrusted({ unitPath })) ?? + (await checkSystemdStopHooks({ unitPath, deps })); + if (blocked !== null) { + logger.error({ message: blocked }); + return 1; + } + // After any rewrite and reload: what systemd will run must be this CLI. + const unexpected = await refuseUnexpectedLoadedProgram({ hooks, deps }); + if (unexpected !== null) { + return unexpected; + } + const unproven = await proveOwnershipBeforeRestart({ + hooks, + unitPath, + deps, + homePid: opts.homePid, + }); + if (unproven !== null) { + return unproven; + } + const replaced = await refuseUntrustedProgramLast({ hooks, unitPath, deps }); + if (replaced !== null) { + return replaced; + } + const why = plan.reasons.includes("version-mismatch") + ? "it runs another hack version" + : "its unit changed"; + logger.info({ message: `Restarting ${SYSTEMD_UNIT_NAME}: ${why}` }); + const restarted = await systemdServiceAction({ action: "restart", deps }); + if (!restarted.ok) { + logger.error({ + message: `systemctl --user restart ${SYSTEMD_UNIT_NAME} failed: ${restarted.error} | Check: ${JOURNAL_HINT}`, + }); + return 1; + } + if (!reloadStillPending) { + await rm(opts.marker, { force: true }); + } + if (await hooks.waitForReady(READY_TIMEOUT_MS)) { + logger.success({ message: "hackd restarted (systemd)" }); + return 0; + } + logger.warn({ + message: `systemd restarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200 and ${JOURNAL_HINT}`, + }); + return 1; +} + /** * The unit is a per-user singleton: starting (or repairing) it from a * shell with another Hack home would start someone else's daemon, and this @@ -302,16 +1005,22 @@ async function checkUnitServesHome(opts: { * installed unit before starting it. Returns an exit code to stop with, or * null to go on and start. * - * Owner decision (HACK-2052): start never rewrites a unit whose program - * still exists, even when this CLI resolves a different `hack`; it reports - * the difference and `hack daemon install` switches it. That keeps a user's - * whole-file edits and never points a login service at a temporary binary. - * Only when the program is gone may start retarget it, and only to a - * durable location ({@link assessExecutableDurability}), replacing just the - * ExecStart program so every other line is kept. Under - * {@link isSystemdReadOnly} (`hack doctor --fix` without a TTY) it neither - * writes nor reloads. A pending reload (`NeedDaemonReload=yes`, left by a - * failed reload after a write) is completed first, so a retry recovers. + * The decision is {@link planDaemonReconcile} (HACK-2216): + * - a unit that runs this CLI's executable (the same path, or one that + * resolves to the same file), starts with {@link SYSTEMD_MANAGED_HEADER} + * and sets no Hack home a rewrite would drop, but differs from what this + * CLI renders for that program is rewritten whole, as install would, keeping + * its program path (a stable symlink stays a symlink) and whether it is + * enabled; + * - a unit whose program is gone is retargeted to this CLI, only to a + * durable location ({@link assessExecutableDurability}), replacing just + * the ExecStart program so every other line is kept; + * - a unit that runs another existing `hack` is never rewritten (HACK-2052: + * the user may have chosen it); start reports the difference and + * `hack daemon install` switches it. + * Under {@link isSystemdReadOnly} (`hack doctor --fix` without a TTY) it + * neither writes nor reloads. A pending reload (`NeedDaemonReload=yes`, left + * by a failed reload after a write) is completed first, so a retry recovers. */ async function prepareSystemdUnitForStart(opts: { readonly status: SystemdServiceStatus; @@ -319,48 +1028,386 @@ async function prepareSystemdUnitForStart(opts: { readonly deps: SystemdDeps; readonly hooks: Pick< SystemdCommandHooks, - "resolveExpected" | "assessDurability" + | "resolveExpected" + | "assessDurability" + | "trace" + | "cliVersion" + | "systemPathDirectories" + | "trustUid" >; }): Promise { const { status, unitPath, deps, hooks } = opts; const readOnly = isSystemdReadOnly(deps.env); - const expected = await ( - hooks.resolveExpected ?? resolveExpectedSystemdUnit - )(); - if (status.drift === "program-unknown") { // Never evidence that the program is missing: leave the unit as it is. logger.info({ message: `${SYSTEMD_UNIT_NAME} has an ExecStart hack does not read (a prefix, specifier or escape); starting it unchanged`, }); } - if (status.drift !== "program-missing") { - return await completePendingReload({ status, deps, expected, readOnly }); + const { plan, expected } = await planStoppedStart({ + status, + unitPath, + deps, + hooks, + }); + recordReconcile({ trace: hooks.trace, manager: "systemd", plan }); + if (plan.service === "refuse") { + logger.error({ + message: `${describeMissingProgram(status)}; not changing the service here | Run: hack daemon install`, + }); + return 1; + } + if (plan.rewrite !== "none" || (status.needsReload && !readOnly)) { + // Trust before the unit is read to keep its directives, rewritten or + // reloaded: the rewrite replaces the file as this user, so a later + // check would pass for text another account planted. + const untrusted = await refuseUntrustedUnit({ hooks, unitPath, deps }); + if (untrusted !== null) { + logger.error({ message: untrusted }); + return 1; + } + } + if (plan.rewrite !== "none") { + return await rewriteUnitForPlan({ + plan, + status, + unitPath, + deps, + hooks, + expected, + }); } + return await completePendingReload({ + status, + deps, + expected, + readOnly, + plan, + }); +} - const missing = `${SYSTEMD_UNIT_NAME} runs a program that no longer exists (${status.program ?? "unreadable ExecStart"})`; - if (readOnly) { - logger.error({ - message: `${missing}; not changing the service here | Run: hack daemon install`, +/** + * The plan for starting the unit with no hackd running: what + * {@link prepareSystemdUnitForStart} does, and what + * {@link refuseSystemdStartBeforeReplacing} checks before it stops a + * running daemon. Reads only; records nothing. + */ +async function planStoppedStart(opts: { + readonly status: SystemdServiceStatus; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly hooks: Pick< + SystemdCommandHooks, + "resolveExpected" | "cliVersion" | "executingProgram" + >; +}): Promise<{ + readonly plan: DaemonReconcilePlan; + readonly expected: { readonly hackBinPath: string } | null; +}> { + const { status, unitPath, deps, hooks } = opts; + const expected = await ( + hooks.resolveExpected ?? resolveExpectedSystemdUnit + )(); + const plan = planDaemonReconcile({ + unit: serviceUnitState(status.drift), + programIsOurs: await unitRunsExpected({ + status, + executing: await resolveExecutingProgram(hooks), + }), + readOnly: isSystemdReadOnly(deps.env), + running: null, + contentBlock: await readContentBlock({ status, unitPath }), + cliVersion: hooks.cliVersion ?? DAEMON_CLI_VERSION, + }); + return { plan, expected }; +} + +/** {@link ServiceUnitState} for a unit's {@link SystemdUnitDrift}. */ +function serviceUnitState(drift: SystemdUnitDrift): ServiceUnitState { + return drift === "none" ? "current" : drift; +} + +/** + * Whether the unit runs the executing CLI's executable + * ({@link resolveExecutingProgram}, not the PATH-selected install target): + * the same path, or a path that resolves to the same file (a stable + * symlink to it). False when the executing CLI has no executable a unit + * could name (a source invocation). + */ +async function unitRunsExpected(opts: { + readonly status: SystemdServiceStatus; + readonly executing: string | null; +}): Promise { + const { status, executing } = opts; + if (status.program === null || executing === null) { + return false; + } + return await isSameExecutableFile(status.program, executing); +} + +/** + * The executing CLI's executable for eligibility decisions + * ({@link resolveExecutingCliPath}); tests pin it with `executingProgram`. + */ +async function resolveExecutingProgram( + hooks: Pick +): Promise { + return hooks.executingProgram + ? await hooks.executingProgram() + : resolveExecutingCliPath(); +} + +/** + * The loaded-executable gate in front of every automatic restart + * (HACK-2216), whatever led to it (a version mismatch, a pending-restart + * marker, a rewrite): the program systemd has loaded must be this CLI's. + * `other` keeps the daemon (`other-program`), `unknown` too + * (`unproven-owner`); a version mismatch is then refused instead. The + * restart-pending marker is left in place. A program retarget is the one + * exception here, since the loaded copy still names the missing program; + * its result is checked after the reload instead + * ({@link refuseUnexpectedLoadedProgram}), as every restart is. + */ +function gateRestartOnLoadedProgram(opts: { + readonly plan: DaemonReconcilePlan; + readonly loaded: "ours" | "other" | "unknown"; +}): DaemonReconcilePlan { + const { plan, loaded } = opts; + if ( + plan.service !== "restart" || + plan.rewrite === "program" || + loaded === "ours" + ) { + return plan; + } + return { + rewrite: "none", + service: plan.reasons.includes("version-mismatch") ? "refuse" : "keep", + reasons: plan.reasons, + blocked: loaded === "unknown" ? "unproven-owner" : "other-program", + }; +} + +/** + * Just before an automatic restart, after any rewrite and reload: the + * program systemd has loaded now must be this CLI's executable, or the + * restart would run another one (a retained drop-in can override the + * rewritten ExecStart). Returns 1 (reported, marker kept, nothing + * restarted) or null to go on. + */ +async function refuseUnexpectedLoadedProgram(opts: { + readonly hooks: Pick; + readonly deps: SystemdDeps; +}): Promise { + const loaded = await loadedProgramIsExpected({ + deps: opts.deps, + executing: await resolveExecutingProgram(opts.hooks), + }); + if (loaded === "ours") { + return null; + } + const blocked = loaded === "unknown" ? "unproven-owner" : "other-program"; + if (opts.hooks.trace) { + opts.hooks.trace.action = "none"; + opts.hooks.trace.blocked = blocked; + } + logger.warn({ + message: `Not restarting ${SYSTEMD_UNIT_NAME}: the program systemd has loaded for it ${loaded === "unknown" ? "cannot be read" : "is not this hack (a drop-in overrides ExecStart)"}, so a restart would not run this version | Check: systemctl --user cat ${SYSTEMD_UNIT_NAME}; then run: hack daemon install`, + }); + return 1; +} + +/** + * Whether the program systemd has loaded for the unit (its single + * ExecStart, drop-ins included: {@link readSystemdLoadedProgram}) is this + * CLI's executable by file identity ({@link isSameExecutableFile}). + * `unknown` when it cannot be read; `other` for any other program. + */ +async function loadedProgramIsExpected(opts: { + readonly deps: SystemdDeps; + /** The executing CLI ({@link resolveExecutingProgram}); null: unknown. */ + readonly executing: string | null; +}): Promise<"ours" | "other" | "unknown"> { + const loaded = await readSystemdLoadedProgram({ deps: opts.deps }); + if (loaded.kind === "unknown" || opts.executing === null) { + return "unknown"; + } + return (await isSameExecutableFile(loaded.path, opts.executing)) + ? "ours" + : "other"; +} + +/** + * Why a content-stale unit must not be rewritten automatically (HACK-2216): + * `unmanaged` when its main file does not start with + * {@link SYSTEMD_MANAGED_HEADER} (owner decision: hand-written units keep the + * HACK-2052 behaviour), `home-binding` when it binds the service to a Hack + * home that the rendered unit would drop ({@link findDroppedHomeBinding}). + */ +async function readContentBlock(opts: { + readonly status: SystemdServiceStatus; + readonly unitPath: string; +}): Promise<"unmanaged" | "home-binding" | null> { + const { status } = opts; + if (status.drift !== "content" || status.program === null) { + return null; + } + const unitText = await readTextFile(opts.unitPath); + if (unitText === null || !isHackManagedUnit({ unitText })) { + return "unmanaged"; + } + const rendered = renderSystemdUnit({ hackBinPath: status.program }); + return rendered === null || findDroppedHomeBinding({ unitText, rendered }) + ? "home-binding" + : null; +} + +/** Says why a stale unit was left as it is, when it was. */ +function noteKeptStaleUnit(opts: { readonly plan: DaemonReconcilePlan }): void { + const { blocked } = opts.plan; + if (blocked === "unmanaged") { + logger.info({ + message: `${SYSTEMD_UNIT_NAME} differs from what this hack writes, but it was not written by hack (no managed header), so it is left as it is | To replace it, run: hack daemon install`, + }); + } else if (blocked === "home-binding") { + logger.warn({ + message: `${SYSTEMD_UNIT_NAME} differs from what this hack writes, but it sets the service's Hack home (HACK_HOME, HOME or an EnvironmentFile) in its main file, which a rewrite would drop; it is left as it is | Move that setting into a drop-in (systemctl --user edit hackd), then run: hack daemon install`, + }); + } else if (blocked === "inside-service") { + logger.warn({ + message: `${SYSTEMD_UNIT_NAME} is stale, but this command runs inside it (a job hackd started), so it was not rewritten or restarted | Run hack daemon start from a shell outside the service`, }); + } +} + +function describeMissingProgram(status: SystemdServiceStatus): string { + return `${SYSTEMD_UNIT_NAME} runs a program that no longer exists (${status.program ?? "unreadable ExecStart"})`; +} + +type UnitRewrite = + | { + readonly unitText: string; + readonly done: string; + /** Reported, not refused (as install reports it). */ + readonly warning?: string; + } + | { readonly error: string }; + +/** + * Carries out a plan's unit rewrite (`content` or `program`) with the + * checks `hack daemon install` uses: the program and the PATH it gives + * hackd must be safe from other accounts, and {@link installSystemdUnit} + * checks the unit directories. An enabled unit stays enabled and a + * disabled one stays disabled. Returns 1 (reported) on failure, else null. + */ +async function rewriteUnitForPlan(opts: { + readonly plan: DaemonReconcilePlan; + readonly status: SystemdServiceStatus; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly hooks: Pick< + SystemdCommandHooks, + "assessDurability" | "systemPathDirectories" | "trace" + >; + readonly expected: { readonly hackBinPath: string } | null; +}): Promise { + const { status, unitPath, deps } = opts; + const rewritten = await computeUnitRewrite(opts); + if ("error" in rewritten) { + logger.error({ message: rewritten.error }); return 1; } - if (!expected) { + if (rewritten.warning) { + logger.warn({ message: rewritten.warning }); + } + // An automatic rewrite never repairs (no `repair`): a directory or unit + // file that became writable after the earlier checks is refused, with + // its mode unchanged, and nothing is written, reloaded or restarted. + const repaired = await installSystemdUnit({ + unitPath, + unitText: rewritten.unitText, + deps, + // Keep the user's choice: a disabled unit is repaired, not re-enabled. + enable: status.enabled, + forceReload: status.needsReload, + }); + if (!repaired.ok) { + if (repaired.untrusted && opts.hooks.trace) { + opts.hooks.trace.action = "none"; + opts.hooks.trace.blocked = "untrusted-unit"; + } logger.error({ - message: `${missing} and no installed hack was found to replace it | Put hack on PATH, then run: hack daemon install`, + message: `Failed to repair ${SYSTEMD_UNIT_NAME}: ${repaired.error}`, }); return 1; } + logger.warn({ message: rewritten.done }); + return null; +} + +/** + * The non-mutating half of a plan's rewrite: the new unit text, or why it + * cannot be written (an unresolvable or non-durable replacement program, + * an unrenderable program, or a program or service PATH that fails + * install's checks). Shared by the real rewrite and by the check that runs + * before a running daemon is stopped ({@link refuseSystemdStartBeforeReplacing}), + * so the two cannot disagree. It reads the file system as it is now: a + * change between the check and the write, or a write or reload failing, + * is outside what it can promise. + */ +async function computeUnitRewrite(opts: { + readonly plan: DaemonReconcilePlan; + readonly status: SystemdServiceStatus; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly hooks: Pick< + SystemdCommandHooks, + "assessDurability" | "systemPathDirectories" + >; + readonly expected: { readonly hackBinPath: string } | null; +}): Promise { + const { plan, status, unitPath, deps, hooks, expected } = opts; + return plan.rewrite === "program" + ? await retargetMissingProgram({ status, unitPath, deps, hooks, expected }) + : await renderCurrentUnit({ status, deps, hooks }); +} + +/** The unit text for a `program` rewrite: only ExecStart is replaced. */ +async function retargetMissingProgram(opts: { + readonly status: SystemdServiceStatus; + readonly unitPath: string; + readonly deps: SystemdDeps; + readonly hooks: Pick< + SystemdCommandHooks, + "assessDurability" | "systemPathDirectories" + >; + readonly expected: { readonly hackBinPath: string } | null; +}): Promise { + const { expected } = opts; + const missing = describeMissingProgram(opts.status); + if (!expected) { + return { + error: `${missing} and no installed hack was found to replace it | Put hack on PATH, then run: hack daemon install`, + }; + } const durability = await ( - hooks.assessDurability ?? assessExecutableDurability + opts.hooks.assessDurability ?? assessExecutableDurability )({ path: expected.hackBinPath }); if (!durability.durable) { - logger.error({ - message: `${missing}. This CLI (${expected.hackBinPath}) is not a stable location for a login service: ${durability.reason} | Install hack to a stable location, then run: hack daemon install`, - }); - return 1; + return { + error: `${missing}. This CLI (${expected.hackBinPath}) is not a stable location for a login service: ${durability.reason} | Install hack to a stable location, then run: hack daemon install`, + }; } - const unitText = await readTextFile(unitPath); + // The new program and the PATH it gives hackd pass install's checks. + const unsafe = await checkInstallPrograms({ + hackBinPath: expected.hackBinPath, + deps: opts.deps, + systemPathDirectories: opts.hooks.systemPathDirectories, + }); + if (unsafe !== null) { + return { error: `${missing}. ${unsafe}` }; + } + const unitText = await readTextFile(opts.unitPath); const retargeted = unitText === null ? null @@ -369,47 +1416,88 @@ async function prepareSystemdUnitForStart(opts: { hackBinPath: expected.hackBinPath, }); if (retargeted === null) { - logger.error({ - message: `${missing} and its ExecStart could not be updated | Run: hack daemon install`, - }); - return 1; + return { + error: `${missing} and its ExecStart could not be updated | Run: hack daemon install`, + }; } - const repaired = await installSystemdUnit({ - unitPath, + return { unitText: retargeted, - deps, - // Keep the user's choice: a disabled unit is repaired, not re-enabled. - enable: status.enabled, - forceReload: status.needsReload, - }); - if (!repaired.ok) { - logger.error({ - message: `Failed to repair ${SYSTEMD_UNIT_NAME}: ${repaired.error}`, - }); - return 1; + done: `Repaired ${SYSTEMD_UNIT_NAME}: its program no longer existed; it now runs ${expected.hackBinPath} (other lines kept)`, + }; +} + +/** + * The unit text for a `content` rewrite: what this CLI renders for the + * unit's own program, after the program and PATH checks install runs. + * Durability is only reported, as install does: the program is unchanged. + */ +async function renderCurrentUnit(opts: { + readonly status: SystemdServiceStatus; + readonly deps: SystemdDeps; + readonly hooks: Pick< + SystemdCommandHooks, + "assessDurability" | "systemPathDirectories" + >; +}): Promise { + const program = opts.status.program; + const unitText = + program === null ? null : renderSystemdUnit({ hackBinPath: program }); + if (program === null || unitText === null) { + return { + error: `${SYSTEMD_UNIT_NAME} differs from what this hack writes, but its program cannot be written into a new unit | Run: hack daemon install`, + }; } - logger.warn({ - message: `Repaired ${SYSTEMD_UNIT_NAME}: its program no longer existed; it now runs ${expected.hackBinPath} (other lines kept)`, + const unsafe = await checkInstallPrograms({ + hackBinPath: program, + deps: opts.deps, + systemPathDirectories: opts.hooks.systemPathDirectories, }); - return null; + if (unsafe !== null) { + return { error: unsafe }; + } + const durability = await ( + opts.hooks.assessDurability ?? assessExecutableDurability + )({ path: program }); + return { + unitText, + done: `Updated ${SYSTEMD_UNIT_NAME}: it differed from what this hack writes for ${program} (local changes belong in a drop-in: systemctl --user edit hackd)`, + ...(durability.durable + ? {} + : { + warning: `The service runs ${program}, which may not last: ${durability.reason}. Install hack to a stable location and run \`hack daemon install\` from there.`, + }), + }; } /** - * Start with an existing program: never rewrite the unit, only note a - * different `hack` and finish a reload that an earlier write left pending. + * Start without a rewrite: note a unit left stale (it runs another `hack`, + * or writes are not allowed here) and finish a reload that an earlier + * write left pending. */ async function completePendingReload(opts: { readonly status: SystemdServiceStatus; readonly deps: SystemdDeps; readonly expected: { readonly hackBinPath: string } | null; readonly readOnly: boolean; + readonly plan: DaemonReconcilePlan; }): Promise { - const { status, expected } = opts; - if (expected && status.program && expected.hackBinPath !== status.program) { + const { status, expected, plan } = opts; + if ( + expected && + status.program && + expected.hackBinPath !== status.program && + !(await isSameExecutableFile(status.program, expected.hackBinPath)) + ) { logger.info({ message: `${SYSTEMD_UNIT_NAME} runs ${status.program}; this CLI is ${expected.hackBinPath}. To switch the service to it, run: hack daemon install`, }); } + if (plan.blocked === "read-only") { + logger.warn({ + message: `${SYSTEMD_UNIT_NAME} differs from what this hack writes; not changing it here | Run: hack daemon install`, + }); + } + noteKeptStaleUnit({ plan }); if (!status.needsReload) { return null; } @@ -849,6 +1937,7 @@ export async function describeSystemdIncompatibleOwner(opts: { readonly pid: number; readonly deps?: SystemdDeps; readonly resolveExpected?: typeof resolveExpectedSystemdUnit; + readonly executingProgram?: () => Promise; readonly isPidInUnit?: (pid: number) => Promise; }): Promise { const deps = opts.deps ?? defaultSystemdDeps(); @@ -872,11 +1961,18 @@ export async function describeSystemdIncompatibleOwner(opts: { ) { return null; } - const expected = await (opts.resolveExpected ?? resolveExpectedSystemdUnit)(); - if (!expected || expected.hackBinPath === status.program) { + // The executing binary, not the PATH-selected `hack`: the same + // executable under another path (a stable symlink to it) is not "another + // hack", and a unit for an older `hack` first on PATH is (HACK-2216). + const executing = await resolveExecutingProgram(opts); + if (await unitRunsExpected({ status, executing })) { return null; } - return `hackd (pid ${opts.pid}) is incompatible with this CLI, and ${SYSTEMD_UNIT_NAME} runs ${status.program}, so restarting it would start the same version again | Run: hack daemon install (switches the service to ${expected.hackBinPath})`; + const expected = await (opts.resolveExpected ?? resolveExpectedSystemdUnit)(); + const target = expected + ? ` (switches the service to ${expected.hackBinPath})` + : ""; + return `hackd (pid ${opts.pid}) is incompatible with this CLI, and ${SYSTEMD_UNIT_NAME} runs ${status.program}, which is not this CLI, so restarting it would not run this version | Run: hack daemon install${target}`; } /** @@ -895,6 +1991,7 @@ export async function withSystemdIncompatibleNextStep< readonly report: T; readonly deps?: SystemdDeps; readonly resolveExpected?: typeof resolveExpectedSystemdUnit; + readonly executingProgram?: () => Promise; }): Promise { const { report } = opts; if ( @@ -908,6 +2005,7 @@ export async function withSystemdIncompatibleNextStep< pid: report.pid, deps: opts.deps, resolveExpected: opts.resolveExpected, + executingProgram: opts.executingProgram, }); return owner === null ? report @@ -1016,6 +2114,9 @@ export async function installDaemonSystemdService( deps, forceReload: before?.needsReload === true, hackHome: dirname(hooks.paths.root), + // Explicit `hack daemon install`: the only caller that repairs Hack's + // own directories and replaces an untrusted unit file (HACK-2216). + repair: true, }); if (!installed.ok) { logger.error({ @@ -1023,6 +2124,7 @@ export async function installDaemonSystemdService( }); return 1; } + recordInstall({ trace: hooks.trace, before, changed: installed.changed }); if (installed.changed) { logger.success({ message: before?.installed @@ -1079,6 +2181,30 @@ export async function installDaemonSystemdService( return ready ? 0 : 1; } +/** Records what install changed, for `--json`. */ +function recordInstall(opts: { + readonly trace: DaemonCommandTrace | undefined; + readonly before: SystemdServiceStatus | null; + readonly changed: boolean; +}): void { + const { trace, before } = opts; + if (!trace) { + return; + } + trace.manager = "systemd"; + if (!opts.changed) { + trace.action = "none"; + return; + } + trace.action = before?.installed ? "reinstalled" : "installed"; + if (before?.installed && before.drift === "content") { + trace.stale.add("content"); + } + if (before?.installed && before.drift === "program-missing") { + trace.stale.add("program-missing"); + } +} + /** * What install checks before it writes anything: the programs and PATH the * new unit uses ({@link checkInstallPrograms}), then the unit as systemd @@ -1268,16 +2394,24 @@ async function noteRestartIfActiveUnitChanges(opts: { readonly unitPath: string; readonly unitText: string; }): Promise { - const marker = resolve(opts.hooks.paths.root, RESTART_PENDING_FILE); if ( opts.before?.active && (await readTextFile(opts.unitPath)) !== opts.unitText ) { - // Owner-only whatever the umask: a shared ~/.hack would fail the - // service PATH check (and hackd's own state-directory check). - await mkdir(opts.hooks.paths.root, { recursive: true, mode: 0o700 }); - await writeFile(marker, "restart hackd.service to load its new unit\n"); + return await writeRestartMarker({ paths: opts.hooks.paths }); } + return resolve(opts.hooks.paths.root, RESTART_PENDING_FILE); +} + +/** Writes the restart-pending marker; returns its path. */ +async function writeRestartMarker(opts: { + readonly paths: DaemonPaths; +}): Promise { + const marker = resolve(opts.paths.root, RESTART_PENDING_FILE); + // Owner-only whatever the umask: a shared ~/.hack would fail the + // service PATH check (and hackd's own state-directory check). + await mkdir(opts.paths.root, { recursive: true, mode: 0o700 }); + await writeFile(marker, "restart hackd.service to load its new unit\n"); return marker; } diff --git a/src/commands/daemon-upgrade.ts b/src/commands/daemon-upgrade.ts new file mode 100644 index 000000000..2c21cb531 --- /dev/null +++ b/src/commands/daemon-upgrade.ts @@ -0,0 +1,312 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import { probeDaemonApi } from "../daemon/client.ts"; +import { getLaunchdServiceStatus } from "../daemon/launchd.ts"; +import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts"; +import { + DAEMON_COMMAND_SCHEMAS, + DAEMON_RECONCILE_ONLY_ENV, + type DaemonCommandAction, + type DaemonServiceManager, + type DaemonStaleReason, +} from "../daemon/reconcile.ts"; +import { readDaemonStatus } from "../daemon/status.ts"; +import { checkServiceExecutableTrusted } from "../daemon/systemd.ts"; +import { childEnvironment } from "../lib/child-environment.ts"; +import { isRecord } from "../lib/guards.ts"; +import { resolveSystemdStatus } from "./daemon-systemd.ts"; + +/** + * What `hack update` did about hackd after installing a new release + * (HACK-2216). Best effort: it never fails the update. + * - `skipped`: no login service is installed for this Hack home + * (`no-service`), or it is installed but hackd is not running + * (`not-running`; nothing is started, and the next `hack daemon start` + * brings the service up to the new version); + * - `reconciled`: the new executable's `hack daemon start --json` ran and + * succeeded; + * - `failed`: it ran and failed, or could not be run; `next_step` says how + * to finish by hand. + */ +export type PostUpdateDaemonResult = + | { + readonly status: "skipped"; + readonly reason: "no-service" | "not-running"; + } + | { + readonly status: "reconciled"; + readonly manager: DaemonServiceManager; + readonly action: DaemonCommandAction; + readonly stale_reasons: readonly DaemonStaleReason[]; + } + | { + readonly status: "failed"; + readonly message: string; + /** + * `hack daemon start` when the new executable was not run at all + * (its path or identity could not be verified); otherwise + * `hack daemon install`. + */ + readonly next_step: "hack daemon install" | "hack daemon start"; + }; + +/** The executable `hack update` just published, as it verified it. */ +export interface InstalledBinary { + readonly dev: number; + readonly ino: number; + readonly sha256: string; +} + +/** What the post-update step needs to know before it runs anything. */ +export interface PostUpdateDaemonState { + readonly serviceInstalled: boolean; + readonly daemonRunning: boolean; +} + +const RECONCILE_TIMEOUT_MS = 60_000; +const MAX_OUTPUT_CHARS = 64 * 1024; + +/** + * Runs the same reconcile as `hack daemon start` with the newly installed + * executable, so the decision uses the new version's unit renderer and + * version (this process is still the old CLI). Only when a login service + * is installed and hackd is running: a daemon that was not running is not + * started. + */ +export async function reconcileDaemonAfterUpdate(opts: { + /** The executable the update just replaced, now the new version. */ + readonly binaryPath: string; + /** + * What the update published at `binaryPath` (device, inode, sha256). The + * step runs `binaryPath` only while it is still exactly that file. + */ + readonly installed?: InstalledBinary; + /** Defaults to {@link verifyInstalledBinary}; tests pin it. */ + readonly verifyBinary?: (opts: { + readonly path: string; + readonly installed: InstalledBinary | undefined; + }) => Promise; + /** Defaults to reading the systemd unit or launchd plist and pid file. */ + readonly observe?: () => Promise; + /** Defaults to running the executable with a bounded wait. */ + readonly runCli?: ( + argv: readonly string[] + ) => Promise<{ readonly exitCode: number; readonly stdout: string }>; +}): Promise { + // Everything here is best effort: the binary is already replaced, so no + // failure (observing included) may fail the update. + try { + const state = await (opts.observe ?? (() => observePostUpdateDaemon()))(); + if (!state.serviceInstalled) { + return { status: "skipped", reason: "no-service" }; + } + if (!state.daemonRunning) { + return { status: "skipped", reason: "not-running" }; + } + // Last, right before the new executable runs as this user: its path is + // safe from other accounts and it is still the file the update + // published and verified (HACK-2216). Otherwise nothing runs. + const unverified = await (opts.verifyBinary ?? verifyInstalledBinary)({ + path: opts.binaryPath, + installed: opts.installed, + }); + if (unverified !== null) { + return { + status: "failed", + message: `the new hack was not run to update hackd: ${unverified}`, + next_step: "hack daemon start", + }; + } + // Reconcile-only: if hackd stopped since it was observed, the child + // does nothing instead of starting it. + const run = await (opts.runCli ?? runBounded)([ + opts.binaryPath, + "daemon", + "start", + "--json", + ]); + return parseStartEnvelope({ exitCode: run.exitCode, stdout: run.stdout }); + } catch (error: unknown) { + return failed(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Reads `hack daemon start --json` output. Anything off the + * `hack.daemon-start.v1` contract is a failure, never a success. + */ +export function parseStartEnvelope(opts: { + readonly exitCode: number; + readonly stdout: string; +}): PostUpdateDaemonResult { + let parsed: unknown; + try { + parsed = JSON.parse(opts.stdout); + } catch { + return failed( + `hack daemon start exited ${opts.exitCode} without a JSON result` + ); + } + if (!isRecord(parsed)) { + return failed("hack daemon start printed an unexpected result"); + } + if (parsed.ok === true && isRecord(parsed.data)) { + const data = parsed.data; + if (data.schema !== DAEMON_COMMAND_SCHEMAS.start || opts.exitCode !== 0) { + return failed("hack daemon start printed an unexpected result"); + } + return { + status: "reconciled", + manager: oneOf(data.manager, ["systemd", "launchd", "none"], "none"), + action: oneOf( + data.action, + ["none", "started", "restarted", "reinstalled", "stopped", "installed"], + "none" + ), + stale_reasons: Array.isArray(data.stale_reasons) + ? data.stale_reasons.filter( + (reason): reason is DaemonStaleReason => + reason === "content" || + reason === "program-missing" || + reason === "version-mismatch" + ) + : [], + }; + } + const error = isRecord(parsed.error) ? parsed.error : null; + return failed( + typeof error?.message === "string" + ? error.message + : `hack daemon start exited ${opts.exitCode}` + ); +} + +function oneOf( + value: unknown, + allowed: readonly T[], + fallback: T +): T { + return allowed.find((candidate) => candidate === value) ?? fallback; +} + +function failed(message: string): PostUpdateDaemonResult { + return { status: "failed", message, next_step: "hack daemon install" }; +} + +/** + * Whether `path` may be run as the new hack after an update (HACK-2216): + * - its path is safe from other accounts (`checkServiceExecutableTrusted`: + * no directory on the way, symlink hops included, can be replaced by + * another account); + * - no link sits at the final component (a regular file, read with lstat); + * - it is still the file the update published: the same device and inode + * and the same sha256 as `publishRelease` verified. + * Null when it may run, or why not. Nothing is run here. + */ +export async function verifyInstalledBinary(opts: { + readonly path: string; + readonly installed: InstalledBinary | undefined; + /** Defaults to `checkServiceExecutableTrusted`; tests pin it. */ + readonly checkTrusted?: (opts: { + readonly path: string; + }) => Promise; +}): Promise { + const installed = opts.installed; + if (installed === undefined) { + return "the update did not record which binary it installed"; + } + const untrusted = await (opts.checkTrusted ?? checkServiceExecutableTrusted)({ + path: opts.path, + }); + if (untrusted !== null) { + return untrusted.replace("hackd.service would run", "it would run"); + } + let info: Awaited>; + try { + info = await lstat(opts.path); + } catch (error: unknown) { + return `${opts.path} could not be inspected: ${error instanceof Error ? error.message : String(error)}`; + } + if (!info.isFile()) { + return `${opts.path} is not a regular file (a link or other entry)`; + } + if ( + Number(info.dev) !== installed.dev || + Number(info.ino) !== installed.ino + ) { + return `${opts.path} was replaced after the update installed it`; + } + const sha256 = createHash("sha256") + .update(await readFile(opts.path)) + .digest("hex"); + return sha256 === installed.sha256 + ? null + : `${opts.path} changed after the update installed it`; +} + +/** + * Whether a login service is installed and this home's hackd is running. + * A live pid alone is not enough (a stale pid file can name a reused pid): + * hackd must answer on this home's socket. + */ +export async function observePostUpdateDaemon( + opts: { + readonly paths?: DaemonPaths; + /** Defaults to reading the systemd unit or launchd plist. */ + readonly serviceInstalled?: () => Promise; + } = {} +): Promise { + const paths = opts.paths ?? resolveDaemonPaths({}); + const serviceInstalled = await ( + opts.serviceInstalled ?? (() => readServiceInstalled({ paths })) + )(); + if (!serviceInstalled) { + return { serviceInstalled, daemonRunning: false }; + } + const daemon = await readDaemonStatus({ paths }); + if (!(daemon.running && daemon.socketExists)) { + return { serviceInstalled, daemonRunning: false }; + } + const probe = await probeDaemonApi({ + socketPath: paths.socketPath, + timeoutMs: 1000, + }); + return { serviceInstalled, daemonRunning: probe.reachable }; +} + +async function readServiceInstalled(opts: { + readonly paths: DaemonPaths; +}): Promise { + if (process.platform === "linux") { + return (await resolveSystemdStatus())?.status.installed === true; + } + if (process.platform === "darwin") { + return (await getLaunchdServiceStatus({ paths: opts.paths })).installed; + } + return false; +} + +async function runBounded( + argv: readonly string[] +): Promise<{ readonly exitCode: number; readonly stdout: string }> { + const proc = Bun.spawn([...argv], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + env: childEnvironment({ + ...process.env, + HACK_NO_INTERACTIVE: "1", + [DAEMON_RECONCILE_ONLY_ENV]: "1", + }), + }); + const timer = setTimeout(() => proc.kill("SIGKILL"), RECONCILE_TIMEOUT_MS); + try { + const [exitCode, stdout] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + return { exitCode, stdout: stdout.slice(0, MAX_OUTPUT_CHARS) }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 482bb2616..b6404c798 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -10,12 +10,13 @@ import { optJson, optTail } from "../cli/options.ts"; import type { DaemonLaunchdConfig } from "../control-plane/sdk/config.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { probeDaemonApi, requestDaemonJson } from "../daemon/client.ts"; +import { executingDaemonArgv } from "../daemon/executing-cli.ts"; import { getLaunchdServiceStatus, installLaunchdService, kickstartLaunchdService, type LaunchdServiceStatus, - repairLaunchdProgramIfInvalid, + readLaunchdManagerState, resolveLaunchdAccess, uninstallLaunchdService, } from "../daemon/launchd.ts"; @@ -34,6 +35,11 @@ import { terminateProvenDaemon, waitForProcessExit, } from "../daemon/process.ts"; +import { + createDaemonCommandTrace, + DAEMON_RECONCILE_ONLY_ENV, + type DaemonCommandTrace, +} from "../daemon/reconcile.ts"; import { runDaemon } from "../daemon/server.ts"; import { type DaemonServiceSummary, @@ -47,6 +53,10 @@ import { type DaemonStatusReport, readDaemonStatus, } from "../daemon/status.ts"; +import { + defaultSystemdDeps, + readSystemdManagerState, +} from "../daemon/systemd.ts"; import { HackCliError } from "../lib/cli-result.ts"; import { updateGlobalConfig } from "../lib/config.ts"; import { readTextFile } from "../lib/fs.ts"; @@ -56,9 +66,24 @@ import { detectDockerBackend, } from "../lib/runtime-guidance.ts"; import { logger } from "../ui/logger.ts"; +import { + checkRunningLaunchdDaemon, + refuseForeignLaunchdProgram, + refuseLaunchdBeforeReplacing, + startViaLaunchdIfLoaded, +} from "./daemon-launchd.ts"; +import { + describeUnverifiedReplacement, + type ReplacementStartOutcome, + type ServiceManagerState, + startReplacementDaemon, +} from "./daemon-replace.ts"; +import { runDaemonCommand } from "./daemon-result.ts"; import { describeSystemdIncompatibleOwner, installDaemonSystemdService, + reconcileRunningSystemdDaemon, + refuseSystemdStartBeforeReplacing, resolveSystemdSummary, startViaSystemdIfInstalled, stopViaSystemdIfInstalled, @@ -75,9 +100,10 @@ const optForeground = defineOption({ const startSpec = defineCommand({ name: "start", - summary: "Start hackd (local daemon)", + summary: + "Start hackd (local daemon); reinstalls and restarts a stale login service", group: "Diagnostics", - options: [optForeground] as const, + options: [optForeground, optJson] as const, positionals: [], subcommands: [], } as const); @@ -86,7 +112,7 @@ const stopSpec = defineCommand({ name: "stop", summary: "Stop hackd", group: "Diagnostics", - options: [], + options: [optJson] as const, positionals: [], subcommands: [], } as const); @@ -131,7 +157,7 @@ const restartSpec = defineCommand({ name: "restart", summary: "Restart hackd", group: "Diagnostics", - options: [], + options: [optJson] as const, positionals: [], subcommands: [], } as const); @@ -169,7 +195,13 @@ const installSpec = defineCommand({ summary: "Install hackd as a login service (launchd on macOS, systemd user unit on Linux)", group: "Diagnostics", - options: [optRunAtLoad, optNoRunAtLoad, optGuiOnly, optNoGuiOnly] as const, + options: [ + optRunAtLoad, + optNoRunAtLoad, + optGuiOnly, + optNoGuiOnly, + optJson, + ] as const, positionals: [], subcommands: [], } as const); @@ -250,7 +282,53 @@ async function handleDaemonStart({ readonly ctx: CliContext; readonly args: DaemonStartArgs; }): Promise { + const json = args.options.json === true; + if (json && args.options.foreground) { + throw new CliUsageError("--json cannot be used with --foreground"); + } const paths = resolveDaemonPaths({}); + return await runDaemonCommand({ + command: "start", + json, + paths, + run: (trace) => + startDaemon({ + paths, + foreground: args.options.foreground === true, + trace, + // A foreground start is a daemon being run (by a manager, or as the + // replacement a reconcile spawned), never a reconcile: the flag + // must not stop it from running. + reconcileOnly: + args.options.foreground !== true && + (process.env[DAEMON_RECONCILE_ONLY_ENV] ?? "").trim() === "1", + }), + }); +} + +/** + * `hack daemon start`. With an installed login service it also brings a + * stale service up to this CLI (HACK-2216): see + * {@link reconcileRunningSystemdDaemon}, `prepareSystemdUnitForStart` and + * {@link startViaLaunchdIfLoaded}. `trace` records what it did for `--json`. + */ +async function startDaemon(opts: { + readonly paths: DaemonPaths; + readonly foreground: boolean; + readonly trace: DaemonCommandTrace; + /** + * {@link DAEMON_RECONCILE_ONLY_ENV} (the post-update step): only bring a + * running hackd and its service up to date; never start one that is not + * running, even if it stopped after the caller looked. + */ + readonly reconcileOnly?: boolean; + /** + * The caller (`restart`) already stopped a running hackd: start its + * replacement with the same fallback as a replacement here. + */ + readonly alreadyReplaced?: boolean; +}): Promise { + const { paths, trace } = opts; const status = await readDaemonStatus({ paths }); // A pid that survived an unclean shutdown may now belong to another @@ -258,103 +336,170 @@ async function handleDaemonStart({ const livePid = status.running ? status.pid : null; const pidIsDaemon = livePid !== null && (await isHackDaemonProcess({ pid: livePid })); + if (opts.reconcileOnly && !(pidIsDaemon && status.socketExists)) { + logger.info({ + message: "hackd is not running; nothing to bring up to date", + }); + return 0; + } + let replaced = opts.alreadyReplaced === true; if (livePid !== null && pidIsDaemon) { const running = await handleRunningDaemonAtStart({ pid: livePid, socketExists: status.socketExists, paths, + trace, + foreground: opts.foreground, }); if (running !== null) { return running; } + replaced = true; + } + const startStopped = () => + startStoppedDaemon({ + paths, + foreground: opts.foreground, + trace, + trackedPid: status.pid, + stalePid: pidIsDaemon ? null : livePid, + replacement: replaced, + }); + if (!replaced || opts.foreground) { + return (await startStopped()).exitCode; } + // A running daemon was just stopped: never leave hackd stopped, unless + // the manager may still start it (see startReplacementDaemon). Any direct + // start runs this CLI itself, and the result must be this CLI's hackd. + return await startReplacementDaemon({ + trace, + startViaService: startStopped, + startDirect: () => + startDaemonDetached({ paths, argv: executingDaemonArgv() }), + isRunning: async () => { + const now = await readDaemonStatus({ paths }); + return now.running && now.socketExists; + }, + managerState: (manager) => readServiceManagerState({ manager, paths }), + verifyStarted: async () => { + const now = await readDaemonStatus({ paths }); + return describeUnverifiedReplacement( + await checkDaemonApi({ socketExists: now.socketExists, paths }) + ); + }, + }); +} - const sweep = await sweepOrphanDaemons({ trackedPid: status.pid, paths }); +/** + * The service manager's state for {@link startReplacementDaemon}; `null`: + * the platform's own manager (systemd on Linux, launchd on macOS). + */ +function readServiceManagerState(opts: { + readonly manager: "systemd" | "launchd" | null; + readonly paths: DaemonPaths; +}): Promise { + const manager = + opts.manager ?? (process.platform === "darwin" ? "launchd" : "systemd"); + if (manager === "launchd") { + return readLaunchdManagerState(); + } + return process.platform === "linux" + ? readSystemdManagerState({ deps: defaultSystemdDeps() }) + : Promise.resolve("idle"); +} + +/** + * `start` once no hackd of this home runs (or it was just replaced). The + * outcome says how far it got, for the replacement fallback. + */ +async function startStoppedDaemon(opts: { + readonly paths: DaemonPaths; + readonly foreground: boolean; + readonly trace: DaemonCommandTrace; + readonly trackedPid: number | null; + /** A live pid already shown not to be hackd. */ + readonly stalePid: number | null; + /** + * A running daemon was just stopped to replace it: a direct start runs + * this CLI itself ({@link executingDaemonArgv}), not the `hack` first on + * PATH, which may be the old version (HACK-2216). + */ + readonly replacement?: boolean; +}): Promise { + const { paths, trace } = opts; + const sweep = await sweepOrphanDaemons({ + trackedPid: opts.trackedPid, + paths, + }); if (!sweep.allStopped) { - return 1; + // A proven orphan still holds the socket: any start would fail. + return { kind: "direct-attempted", exitCode: 1, manager: null }; } // An installed hackd.service owns the daemon on Linux. Start through it // before touching the pid/socket files: systemd (a pending restart) or // another starter may be writing them right now, and hackd replaces its // own socket when it starts. - if (process.platform === "linux" && !args.options.foreground) { + if (process.platform === "linux" && !opts.foreground) { + let requested = false; const viaSystemd = await startViaSystemdIfInstalled({ paths, waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + trace, + onStartRequested: () => { + requested = true; + }, }); if (viaSystemd !== null) { - return viaSystemd; + return { + kind: requested ? "service-attempted" : "service-refused", + exitCode: viaSystemd, + manager: "systemd", + }; } + // The unit serves another home, or systemd is unusable: hackd starts + // directly, whatever was noted about the unit. + trace.manager = "none"; } const raced = await clearStaleDaemonFilesBeforeStart({ paths, - stalePid: pidIsDaemon ? null : livePid, + stalePid: opts.stalePid, }); if (raced !== null) { - return raced; + // Another starter owns this start. + return { kind: "direct-attempted", exitCode: raced, manager: null }; } - if (args.options.foreground) { + if (opts.foreground) { await runDaemon({ paths, foreground: true }); - return 0; + return { kind: "direct-attempted", exitCode: 0, manager: null }; } - const viaLaunchd = await startViaLaunchdIfLoaded({ paths }); + let requested = false; + const viaLaunchd = await startViaLaunchdIfLoaded({ + paths, + trace, + waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + onStartRequested: () => { + requested = true; + }, + }); if (viaLaunchd !== null) { - return viaLaunchd; + return { + kind: requested ? "service-attempted" : "service-refused", + exitCode: viaLaunchd, + manager: "launchd", + }; } - return await startDaemonDetached({ paths }); -} - -/** - * When launchd manages the daemon, start through launchd. Returns null when - * no launchd service is loaded (unchanged macOS behavior, moved out of - * `handleDaemonStart`). - */ -async function startViaLaunchdIfLoaded(opts: { - readonly paths: DaemonPaths; -}): Promise { - const { paths } = opts; - // When launchd manages the daemon, start through launchd — spawning a - // bare process next to a loaded agent means two process managers fight - // over one socket and pid file. - const launchdStatus = await resolveLaunchdStatus({ paths }); - if (!launchdStatus?.loaded) { - return null; - } - const repair = await repairLaunchdProgramIfInvalid({ paths }); - if (repair === "repaired") { - logger.warn({ - message: - "Repaired launchd service: its program path was invalid (stale or compiled-binary virtual path)", - }); - } - // launchd owns the daemon: never spawn a bare process next to it — - // two process managers for one socket is how machines end up with - // start-then-exit flapping and stale-pid contradictions. - const kick = await kickstartLaunchdService(); - if (!kick.ok) { - logger.error({ - message: `launchd kickstart failed: ${kick.error ?? "unknown error"} | Check: hack daemon logs --tail 200`, - }); - return 1; - } - const startedViaLaunchd = await waitForDaemonStart({ + const started = await startDaemonDetached({ paths, - timeoutMs: 8000, - }); - if (startedViaLaunchd) { - logger.success({ message: "hackd started (launchd)" }); - return 0; - } - logger.warn({ - message: - "launchd kickstarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200", + argv: opts.replacement === true ? executingDaemonArgv() : undefined, }); - return 1; + trace.manager = "none"; + trace.action = started === 0 ? "started" : trace.action; + return { kind: "direct-attempted", exitCode: started, manager: null }; } /** @@ -406,6 +551,16 @@ async function sweepOrphanDaemons(opts: { trackedPid: opts.trackedPid, daemonRoot: opts.paths.root, }); + if (scan.unknown) { + // No trusted `ps` at a fixed path (HACK-2216): orphans are unknown, so + // nothing is stopped and the start does not go on as if none held + // the socket. + logger.error({ + message: + "Could not read the process table (no ps at a fixed, trusted path), so orphaned hackd processes are unknown; nothing was stopped or started | Check: hack daemon status", + }); + return { allStopped: false, stopped: 0 }; + } for (const refused of scan.refused) { logger.info({ message: `Left pid ${refused.pid} running: it looks like a hack daemon start, but ${describeDaemonProcessRefusal(refused.reason)}`, @@ -465,23 +620,52 @@ export async function stopProvenDaemon(opts: { /** * `hack daemon start` found a live pid. Returns an exit code when start is - * done (already running, or an incompatible systemd-run hackd that only - * `hack daemon install` can switch), or null after replacing an - * incompatible directly started hackd. + * done (already running, a stale login service brought up to this CLI, or + * an incompatible systemd-run hackd that only `hack daemon install` can + * switch), or null after replacing an incompatible directly started hackd. */ async function handleRunningDaemonAtStart(opts: { readonly pid: number; readonly socketExists: boolean; readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; + readonly foreground: boolean; }): Promise { + const { paths, trace } = opts; const api = await checkDaemonApi({ socketExists: opts.socketExists, - paths: opts.paths, + paths, }); - if (!(api.reachable && !api.compatible)) { + if (process.platform === "linux" && !opts.foreground) { + const reconciled = await reconcileRunningSystemdDaemon({ + paths, + pid: opts.pid, + version: api.reachable ? api.version : null, + waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + trace, + }); + if (reconciled !== null) { + return reconciled; + } + } + const compatible = !(api.reachable && !api.compatible); + if (process.platform === "darwin" && !opts.foreground) { + const viaLaunchd = await checkRunningLaunchdDaemon({ + paths, + trace, + pid: opts.pid, + compatible, + waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + }); + if (viaLaunchd !== null) { + return viaLaunchd; + } + } + if (compatible) { logger.success({ message: `hackd already running (pid ${opts.pid})` }); return 0; } + trace.stale.add("version-mismatch"); // systemd runs this hackd from a unit that points at another `hack`: // replacing it would only make systemd start the same old binary again. // Say so instead (start never rewrites such a unit). @@ -506,6 +690,20 @@ async function handleRunningDaemonAtStart(opts: { }); return 1; } + // Refuse before stopping when the start that follows would be refused: + // otherwise a running hackd is left stopped (HACK-2216). The launchd case + // was checked above (checkRunningLaunchdDaemon). + if (process.platform === "linux" && !opts.foreground) { + const refusal = await refuseSystemdStartBeforeReplacing({ paths }); + if (refusal !== null) { + trace.manager = "systemd"; + trace.blocked = refusal.blocked; + logger.warn({ + message: `hackd (pid ${opts.pid}) is incompatible with this CLI, but it was not replaced, because hackd.service could not be started for this home afterwards: ${refusal.message}`, + }); + return 1; + } + } logger.warn({ message: `Replacing incompatible hackd (pid ${opts.pid})` }); if (!(await stopProvenDaemon({ proof, paths: opts.paths }))) { return 1; @@ -556,18 +754,26 @@ async function clearStaleDaemonFilesBeforeStart(opts: { */ async function startDaemonDetached(opts: { readonly paths: DaemonPaths; + /** + * What to run. Defaults to the `hack` this CLI resolves (an ordinary + * start); a replacement passes {@link executingDaemonArgv}, this CLI + * itself (HACK-2216). + */ + readonly argv?: readonly string[]; }): Promise { const { paths } = opts; - const invocation = await resolveHackInvocation(); - spawnDetachedDaemon({ - argv: [ + let argv = opts.argv; + if (argv === undefined) { + const invocation = await resolveHackInvocation(); + argv = [ invocation.bin, ...invocation.args, "daemon", "start", "--foreground", - ], - }); + ]; + } + spawnDetachedDaemon({ argv: [...argv] }); const started = await waitForDaemonStart({ paths }); if (!started) { @@ -582,12 +788,39 @@ async function startDaemonDetached(opts: { } async function handleDaemonStop({ - args: _args, + args, }: { readonly ctx: CliContext; readonly args: DaemonStopArgs; }): Promise { const paths = resolveDaemonPaths({}); + return await runDaemonCommand({ + command: "stop", + json: args.options.json === true, + paths, + run: (trace) => stopDaemon({ paths, trace }), + }); +} + +/** `hack daemon stop`; `trace` records who stopped what for `--json`. */ +async function stopDaemon(opts: { + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; +}): Promise { + const { paths, trace } = opts; + const code = await stopDaemonSteps({ paths, trace }); + if (trace.manager === "none" && process.platform === "darwin") { + const launchd = await resolveLaunchdStatus({ paths }); + trace.manager = launchd?.loaded ? "launchd" : "none"; + } + return code; +} + +async function stopDaemonSteps(opts: { + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; +}): Promise { + const { paths, trace } = opts; const initial = await readDaemonStatus({ paths }); // An installed hackd.service is stopped through systemd even when the pid @@ -619,6 +852,10 @@ async function handleDaemonStop({ pollMs: 200, }); } + if (viaSystemd.handled) { + trace.manager = "systemd"; + trace.action = "stopped"; + } const status = viaSystemd.handled ? await readDaemonStatus({ paths }) @@ -663,6 +900,7 @@ async function handleDaemonStop({ if (!(await stopProvenDaemon({ proof, paths }))) { return 1; } + trace.action = "stopped"; logger.success({ message: "hackd stopped" }); return 0; } @@ -722,15 +960,23 @@ async function handleDaemonStatus({ async function checkDaemonApi(opts: { readonly socketExists: boolean; readonly paths: DaemonPaths; -}): Promise<{ readonly reachable: boolean; readonly compatible: boolean }> { +}): Promise<{ + readonly reachable: boolean; + readonly compatible: boolean; + readonly version: string | null; +}> { if (!opts.socketExists) { - return { reachable: false, compatible: false }; + return { reachable: false, compatible: false, version: null }; } const probe = await probeDaemonApi({ socketPath: opts.paths.socketPath, timeoutMs: 500, }); - return { reachable: probe.reachable, compatible: probe.compatible }; + return { + reachable: probe.reachable, + compatible: probe.compatible, + version: probe.version, + }; } async function resolveLaunchdStatus(opts: { @@ -954,48 +1200,125 @@ async function handleDaemonClear({ } async function handleDaemonRestart({ - args: _args, - ctx, + args, }: { readonly ctx: CliContext; readonly args: DaemonRestartArgs; }): Promise { - const stopArgs: DaemonStopArgs = { - options: {}, - positionals: {}, - raw: { argv: [], positionals: [] }, - }; - const startArgs: DaemonStartArgs = { - options: { foreground: false }, - positionals: {}, - raw: { argv: [], positionals: [] }, - }; + const paths = resolveDaemonPaths({}); + return await runDaemonCommand({ + command: "restart", + json: args.options.json === true, + paths, + run: (trace) => restartDaemon({ paths, trace }), + }); +} + +/** + * `hack daemon restart`: stop, then start, which also brings a stale login + * service up to this CLI. `trace` reports the start half. + */ +/** + * Before `restart` stops this home's running hackd: the replacement checks + * `start` uses (launchd: the loaded job must be this home's; systemd: the + * unit's start after the stop must not be refused). Exit code (reported) + * or null to go on. + */ +async function refuseRestartBeforeStop(opts: { + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; +}): Promise { + const { paths, trace } = opts; + if (process.platform === "darwin") { + return await refuseLaunchdBeforeReplacing({ + paths, + trace, + waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + }); + } + if (process.platform !== "linux") { + return null; + } + const refusal = await refuseSystemdStartBeforeReplacing({ paths }); + if (refusal === null) { + return null; + } + trace.manager = "systemd"; + trace.blocked = refusal.blocked; + logger.warn({ + message: `Not restarting hackd: hackd.service could not be started for this home afterwards, so nothing was stopped: ${refusal.message}`, + }); + return 1; +} +async function restartDaemon(opts: { + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; +}): Promise { + const { paths, trace } = opts; + // A launchd service that runs another existing (or unreadable) program + // is refused before anything is stopped: the later start would kickstart + // a program this CLI does not own. + if (process.platform === "darwin") { + const foreign = await refuseForeignLaunchdProgram({ + paths, + trace, + verb: "restart", + waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + }); + if (foreign !== null) { + return foreign; + } + } // Restarting an incompatible hackd that systemd runs from another, // still existing `hack` only starts the same version again. - const paths = resolveDaemonPaths({}); const status = await readDaemonStatus({ paths }); - if (process.platform === "linux" && status.running && status.pid !== null) { + if (status.running && status.pid !== null) { const api = await checkDaemonApi({ socketExists: status.socketExists, paths, }); + const incompatible = api.reachable && !api.compatible; + if (incompatible) { + trace.stale.add("version-mismatch"); + } const owner = - api.reachable && !api.compatible + incompatible && process.platform === "linux" ? await describeSystemdIncompatibleOwner({ pid: status.pid }) : null; if (owner !== null) { + trace.manager = "systemd"; logger.warn({ message: owner }); return 1; } + // The same eligibility checks `start` runs before replacing a running + // daemon: refuse now, with nothing stopped, when the start after the + // stop would be refused. + const blocked = await refuseRestartBeforeStop({ paths, trace }); + if (blocked !== null) { + return blocked; + } } // A refused stop (the pid could not be proven to be this HACK_HOME's // hackd) must not turn into "already running" success. - const stopped = await handleDaemonStop({ ctx, args: stopArgs }); + const stopTrace = createDaemonCommandTrace(); + const stopped = await stopDaemon({ paths, trace: stopTrace }); if (stopped !== 0) { + trace.manager = stopTrace.manager; return stopped; } - return await handleDaemonStart({ ctx, args: startArgs }); + // A daemon that ran before the stop gets the same idle-gated direct + // fallback as a replacement in `start`. + const started = await startDaemon({ + paths, + foreground: false, + trace, + alreadyReplaced: status.running, + }); + if (started === 0 && trace.action === "started") { + trace.action = "restarted"; + } + return started; } async function waitForDaemonStart({ @@ -1022,8 +1345,22 @@ async function handleDaemonInstall({ readonly ctx: CliContext; readonly args: DaemonInstallArgs; }): Promise { + const paths = resolveDaemonPaths({}); + return await runDaemonCommand({ + command: "install", + json: args.options.json === true, + paths, + run: (trace) => installDaemon({ args, paths, trace }), + }); +} + +async function installDaemon(opts: { + readonly args: DaemonInstallArgs; + readonly paths: DaemonPaths; + readonly trace: DaemonCommandTrace; +}): Promise { + const { args, paths, trace } = opts; if (process.platform === "linux") { - const paths = resolveDaemonPaths({}); return await installDaemonSystemdService({ paths, ignoredOptions: ( @@ -1034,6 +1371,7 @@ async function handleDaemonInstall({ startDetached: () => startDaemonDetached({ paths }), stopDirect: (proof) => stopProvenDaemon({ proof, paths }), waitForReady: (timeoutMs) => waitForDaemonStart({ paths, timeoutMs }), + trace, }); } if (process.platform !== "darwin") { @@ -1044,8 +1382,8 @@ async function handleDaemonInstall({ return 1; } assertLaunchdAccess(); + trace.manager = "launchd"; - const paths = resolveDaemonPaths({}); const controlPlane = await readControlPlaneConfig({}); const runAtLoad = resolveRunAtLoadOption({ @@ -1092,6 +1430,7 @@ async function handleDaemonInstall({ message: "hackd launchd service already installed (config unchanged)", }); } else { + trace.action = "installed"; logger.success({ message: "hackd launchd service installed" }); } diff --git a/src/commands/update.ts b/src/commands/update.ts index 8cccbc9b3..3ea6e6432 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -33,6 +33,11 @@ import { selectCliTarballAsset, } from "../lib/self-update.ts"; import { observeUpdate } from "../lib/update-observe.ts"; +import { + type InstalledBinary, + type PostUpdateDaemonResult, + reconcileDaemonAfterUpdate, +} from "./daemon-upgrade.ts"; const optCheck = defineOption({ name: "check", @@ -262,6 +267,7 @@ const handleUpdate: CommandHandlerFor = async ({ } let backupManifest: string | undefined; + let installedBinary: InstalledBinary | undefined; try { const install = await installExtractedRelease({ version: latestVersion, @@ -276,6 +282,7 @@ const handleUpdate: CommandHandlerFor = async ({ }); } backupManifest = install.backupManifest; + installedBinary = install.installedBinary; } finally { // Best-effort cleanup; avoid failing the update after a successful install. await rm(extracted.tmpDir, { recursive: true, force: true }).catch( @@ -284,6 +291,12 @@ const handleUpdate: CommandHandlerFor = async ({ } const mutagenProvision = await ensureMutagenAfterUpdate(); + // An installed login service still runs the old hackd: bring it up to + // the new executable (best effort; never fails the update). + const daemon = await reconcileDaemonAfterUpdate({ + binaryPath: bin.path, + installed: installedBinary, + }); return writeResult({ json: args.options.json === true, @@ -298,11 +311,30 @@ const handleUpdate: CommandHandlerFor = async ({ assetsDir, mutagen: mutagenProvision, backupManifest, + daemon, }, - human: `Updated to v${latestVersion}. Backup manifest: ${backupManifest}`, + human: [ + `Updated to v${latestVersion}. Backup manifest: ${backupManifest}`, + ...describePostUpdateDaemon(daemon), + ].join("\n"), }); }; +/** The human line for {@link reconcileDaemonAfterUpdate}, if any. */ +function describePostUpdateDaemon(result: PostUpdateDaemonResult): string[] { + if (result.status === "skipped") { + return []; + } + if (result.status === "failed") { + return [ + `hackd was not updated: ${result.message} | Run: ${result.next_step}`, + ]; + } + return result.action === "none" + ? [] + : [`hackd ${result.action} (${result.manager}) on the new version.`]; +} + /** * Read-only report for every install kind, including Homebrew and candidate * launchers that `hack update` refuses to modify (HACK-2218 R14). @@ -501,6 +533,7 @@ type UpdateOutput = readonly assetsDir: string; readonly mutagen?: MutagenProvisionResult; readonly backupManifest?: string; + readonly daemon?: PostUpdateDaemonResult; } | { readonly ok: false; diff --git a/src/daemon/client.ts b/src/daemon/client.ts index f0c394d95..d17d03417 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -1,6 +1,7 @@ import pkg from "../../package.json"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; +import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import { canPrompt } from "../lib/interactivity.ts"; @@ -8,7 +9,9 @@ import { getLaunchdServiceStatus, installLaunchdService, kickstartLaunchdService, + launchdJobMayBeRunning, resolveLaunchdAccess, + verifyLaunchdJobBeforeKickstart, } from "./launchd.ts"; import { resolveDaemonPaths } from "./paths.ts"; import { spawnDetachedDaemon } from "./spawn.ts"; @@ -20,6 +23,8 @@ type PackageJsonType = { } & Record; const packageJson = pkg as unknown as PackageJsonType; +/** This CLI's version; a hackd reporting another one is incompatible. */ +export const DAEMON_CLI_VERSION: string = packageJson.version; const DAEMON_AUTOSTART_COOLDOWN_MS = 15_000; const DAEMON_AUTOSTART_WAIT_MS = 3500; const DAEMON_AUTOSTART_POLL_MS = 125; @@ -301,6 +306,27 @@ async function ensureLaunchdDaemonReady(opts: { } } + // Last, right before launchd is asked to start the job: its program and + // the PATH it gives hackd, in the plist and in the configuration launchd + // has loaded (HACK-2216). On a refusal the autostart falls back to + // `hack daemon start`, which refuses the same job with the reason. + const plistText = await readTextFile(opts.paths.launchdPlistPath); + if (plistText === null) { + return false; + } + const refused = await verifyLaunchdJobBeforeKickstart({ + paths: opts.paths, + plistText, + // Read fresh, only when a reload is about to happen. + isRunning: async () => + launchdJobMayBeRunning( + await getLaunchdServiceStatus({ paths: opts.paths }) + ), + }); + if (refused !== null) { + return false; + } + const kick = await kickstartLaunchdService(); if (!kick.ok) { return false; diff --git a/src/daemon/executing-cli.ts b/src/daemon/executing-cli.ts new file mode 100644 index 000000000..a8908b282 --- /dev/null +++ b/src/daemon/executing-cli.ts @@ -0,0 +1,57 @@ +/** + * The executable of the CLI running now, for deciding whether a service + * runs "this CLI" (HACK-2216). Distinct from the `hack` this CLI would + * install (the first on PATH, `resolveExpectedSystemdUnit` and + * `resolveLaunchdHackBinPath`): `hack update` runs the new binary by + * absolute path while an older `hack` may come first on PATH, and a + * service running that older one is not this CLI. + * + * A compiled Hack binary (its entry is inside the binary's virtual + * filesystem) is `process.execPath`. A source invocation (`bun index.ts`) + * has no executable a unit or plist could name, so it is null: unknown, + * which never makes a service eligible for an automatic restart or + * refresh. + */ +export function resolveExecutingCliPath( + opts: { readonly main?: string; readonly execPath?: string } = {} +): string | null { + const main = opts.main ?? Bun.main; + // A compiled Bun binary's entry lives in its virtual filesystem. + const compiled = main.startsWith("/$bunfs/") || main.includes("~BUN"); + return compiled ? (opts.execPath ?? process.execPath) : null; +} + +/** + * How to run THIS CLI again (HACK-2216): the executing compiled binary, or, + * for a source invocation, the same runtime with the same entry point + * (`bun index.ts`). Never the `hack` first on PATH: during `hack update` + * the new binary runs by absolute path while an older `hack` may come + * first on PATH, and a replacement daemon started from that one would run + * the old version. + */ +export function resolveExecutingInvocation( + opts: { readonly main?: string; readonly execPath?: string } = {} +): { readonly bin: string; readonly args: readonly string[] } { + const executing = resolveExecutingCliPath(opts); + if (executing !== null) { + return { bin: executing, args: [] }; + } + return { + bin: opts.execPath ?? process.execPath, + args: [opts.main ?? Bun.main], + }; +} + +/** The argv that starts this CLI's hackd in the foreground. */ +export function executingDaemonArgv( + opts: { readonly main?: string; readonly execPath?: string } = {} +): readonly string[] { + const invocation = resolveExecutingInvocation(opts); + return [ + invocation.bin, + ...invocation.args, + "daemon", + "start", + "--foreground", + ]; +} diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts new file mode 100644 index 000000000..c10d211e4 --- /dev/null +++ b/src/daemon/launchd-plist.ts @@ -0,0 +1,400 @@ +/** + * Reads which executable a launchd plist runs, from the keys of its root + * `` only (HACK-2216). The reader is deliberately small: anything it + * does not fully understand is `unknown`, and unknown is never evidence + * that the program is missing, so callers leave such a plist as it is. + */ + +/** + * `path`: the executable, with launchd's precedence (`Program` when present, + * else `ProgramArguments[0]`). `none`: neither key is a root key (or the + * plist is empty), so nothing is run: absence established. `unknown`: see + * {@link parseLaunchdPlistProgram}. + */ +export type LaunchdPlistProgram = + | { readonly kind: "path"; readonly path: string } + | { readonly kind: "none" } + | { readonly kind: "unknown"; readonly reason: string }; + +type Token = + | { readonly kind: "open" | "close" | "empty"; readonly name: string } + | { readonly kind: "text"; readonly text: string }; + +type PlistValue = + | { readonly kind: "string"; readonly text: string } + | { readonly kind: "array"; readonly items: readonly PlistValue[] } + | { + readonly kind: "dict"; + readonly keys: ReadonlyMap; + } + | { readonly kind: "other" }; + +const PROLOG = /^\s*(<\?xml[^?]*\?>)?\s*([]*>)?/; +const COMMENT = //g; +const TAG = /^<(\/?)([A-Za-z][\w.-]*)(\s+[^<>]*?)?(\/?)>/; +/** Values that hold plain text (a `` is read by the dict reader). */ +const SCALAR_TAGS = new Set(["string", "integer", "real", "date", "data"]); +const EMPTY_VALUE_TAGS = new Set(["true", "false", "string", "array", "dict"]); + +class PlistShapeError extends Error {} + +/** + * Unknown when: the text is not a property list; it has an unterminated + * comment, CDATA, a processing instruction or a doctype past the prolog; + * its root is not a ``; a tag is malformed or unbalanced; a root key + * repeats; `Program` is not a string or `ProgramArguments` not an array + * starting with a string; or the path uses an XML escape or is relative. + * Comments are dropped as launchd drops them, and keys of nested dicts + * (for example inside `EnvironmentVariables`) never count. + */ +export function parseLaunchdPlistProgram(opts: { + readonly plistText: string; +}): LaunchdPlistProgram { + let root: Map | null; + try { + root = readRootDict(opts.plistText); + } catch (error: unknown) { + return { + kind: "unknown", + reason: error instanceof PlistShapeError ? error.message : "unreadable", + }; + } + if (root === null) { + return { kind: "none" }; + } + const program = root.get("Program"); + if (program !== undefined) { + return program.kind === "string" + ? programPath(program.text) + : { kind: "unknown", reason: "Program is not a plain string value" }; + } + const args = root.get("ProgramArguments"); + if (args === undefined) { + return { kind: "none" }; + } + const first = args.kind === "array" ? args.items[0] : undefined; + return first?.kind === "string" + ? programPath(first.text) + : { + kind: "unknown", + reason: "ProgramArguments is not an array that starts with a string", + }; +} + +/** + * The whole command a plist runs (HACK-2216): launchd's program (see + * {@link parseLaunchdPlistProgram}: `Program` first) and the complete + * `ProgramArguments` vector, every item a plain string without an XML + * escape. `unknown` for anything else, including a plist with no + * `ProgramArguments`. + */ +export function parseLaunchdPlistCommand(opts: { readonly plistText: string }): + | { + readonly kind: "command"; + readonly program: string; + readonly arguments: readonly string[]; + } + | { readonly kind: "unknown"; readonly reason: string } { + const program = parseLaunchdPlistProgram(opts); + if (program.kind !== "path") { + return { + kind: "unknown", + reason: + program.kind === "unknown" ? program.reason : "the plist runs nothing", + }; + } + // Read already by parseLaunchdPlistProgram: the shape is valid here. + const args = readRootDict(opts.plistText)?.get("ProgramArguments"); + if (args?.kind !== "array") { + return { kind: "unknown", reason: "ProgramArguments is not an array" }; + } + const strings: string[] = []; + for (const item of args.items) { + if (item.kind !== "string" || item.text.includes("&")) { + return { + kind: "unknown", + reason: "ProgramArguments holds a value that is not a plain string", + }; + } + strings.push(item.text); + } + return { kind: "command", program: program.path, arguments: strings }; +} + +/** + * The plist's root `EnvironmentVariables` (HACK-2216), for the home + * binding check before an automatic rewrite: `env` maps each name to its + * string value (null for a non-string value); an absent dict is empty. + * `unknown` when the plist cannot be read ({@link parseLaunchdPlistProgram}) + * or `EnvironmentVariables` is not a dict. + */ +export function parseLaunchdPlistEnvironment(opts: { + readonly plistText: string; +}): + | { + readonly kind: "env"; + readonly env: ReadonlyMap; + } + | { readonly kind: "unknown"; readonly reason: string } { + let root: Map | null; + try { + root = readRootDict(opts.plistText); + } catch (error: unknown) { + return { + kind: "unknown", + reason: error instanceof PlistShapeError ? error.message : "unreadable", + }; + } + const value = root?.get("EnvironmentVariables"); + if (value === undefined) { + return { kind: "env", env: new Map() }; + } + if (value.kind !== "dict") { + return { + kind: "unknown", + reason: "EnvironmentVariables is not a dict", + }; + } + const env = new Map(); + for (const [name, entry] of value.keys) { + env.set(name, entry.kind === "string" ? entry.text : null); + } + return { kind: "env", env }; +} + +function programPath(raw: string): LaunchdPlistProgram { + // Exactly as written: launchd does not trim string values. + const path = raw; + if (path.includes("&")) { + return { kind: "unknown", reason: "the program path uses an XML escape" }; + } + if (!path.startsWith("/")) { + return { kind: "unknown", reason: "the program path is not absolute" }; + } + return { kind: "path", path }; +} + +/** The root dict's keys, or null for an empty ``. Throws on shape. */ +function readRootDict(text: string): Map | null { + const tokens = tokenize(text); + let i = skipSpace(tokens, 0); + const plist = tokens[i]; + if (plist?.kind === "empty" && plist.name === "plist") { + expectEnd(tokens, i + 1); + return null; + } + if (!(plist?.kind === "open" && plist.name === "plist")) { + throw new PlistShapeError("not a property list"); + } + i = skipSpace(tokens, i + 1); + const first = tokens[i]; + if (first?.kind === "close" && first.name === "plist") { + expectEnd(tokens, i + 1); + return null; + } + if (first?.kind === "empty" && first.name === "dict") { + i = expectClose(tokens, skipSpace(tokens, i + 1), "plist"); + expectEnd(tokens, i); + return new Map(); + } + if (!(first?.kind === "open" && first.name === "dict")) { + throw new PlistShapeError("the plist root is not a dict"); + } + const parsed = readDictBody(tokens, i + 1, true); + i = expectClose(tokens, skipSpace(tokens, parsed.next), "plist"); + expectEnd(tokens, i); + return parsed.keys; +} + +function tokenize(input: string): Token[] { + const text = input.replace(PROLOG, "").replace(COMMENT, ""); + if (text.includes(" 0) { + const lt = rest.indexOf("<"); + if (lt < 0) { + tokens.push({ kind: "text", text: rest }); + break; + } + if (lt > 0) { + tokens.push({ kind: "text", text: rest.slice(0, lt) }); + rest = rest.slice(lt); + continue; + } + const tag = readTag(rest); + tokens.push(tag.token); + rest = rest.slice(tag.length); + } + return tokens; +} + +function readTag(rest: string): { + readonly token: Token; + readonly length: number; +} { + const match = TAG.exec(rest); + if (match === null) { + throw new PlistShapeError("it has a malformed tag"); + } + const [whole, closing, name = "", , selfClosing] = match; + if (closing && selfClosing) { + throw new PlistShapeError("it has a malformed tag"); + } + let kind: "open" | "close" | "empty" = "open"; + if (closing) { + kind = "close"; + } else if (selfClosing) { + kind = "empty"; + } + return { token: { kind, name }, length: whole.length }; +} + +function skipSpace(tokens: readonly Token[], start: number): number { + let i = start; + while (true) { + const token = tokens[i]; + if (!(token?.kind === "text" && token.text.trim() === "")) { + return i; + } + i += 1; + } +} + +function expectClose( + tokens: readonly Token[], + i: number, + name: string +): number { + const token = tokens[i]; + if (!(token?.kind === "close" && token.name === name)) { + throw new PlistShapeError(`it has an unbalanced <${name}>`); + } + return i + 1; +} + +function expectEnd(tokens: readonly Token[], i: number): void { + if (skipSpace(tokens, i) !== tokens.length) { + throw new PlistShapeError("it has content after the root element"); + } +} + +/** + * Reads ``/value pairs up to ``. Keys are only ever looked up + * in the dict a caller asks for (Program in the root, HACK_HOME in the + * root's EnvironmentVariables), never searched across nesting. + */ +function readDictBody( + tokens: readonly Token[], + start: number, + keep: boolean +): { readonly keys: Map; readonly next: number } { + const keys = new Map(); + let i = start; + while (true) { + i = skipSpace(tokens, i); + const token = tokens[i]; + if (token?.kind === "close" && token.name === "dict") { + return { keys, next: i + 1 }; + } + if (!(token?.kind === "open" && token.name === "key")) { + throw new PlistShapeError("a dict entry is not a "); + } + const key = readScalar(tokens, i, "key"); + const value = readValue(tokens, skipSpace(tokens, key.next)); + // Key identity is the exact text: launchd neither trims nor knows + // another spelling. An entity or character reference (`Program` + // is `Program` to launchd) is not decoded here, so it is unknown. + const name = key.text; + if (keep && name.includes("&")) { + throw new PlistShapeError("a key uses an XML escape"); + } + if (keep && keys.has(name)) { + throw new PlistShapeError(`the key ${name} appears twice`); + } + if (keep) { + keys.set(name, value.value); + } + i = value.next; + } +} + +function readScalar( + tokens: readonly Token[], + start: number, + name: string +): { readonly text: string; readonly next: number } { + const body = tokens[start + 1]; + if (body?.kind === "close" && body.name === name) { + return { text: "", next: start + 2 }; + } + if (body?.kind !== "text") { + throw new PlistShapeError(`a <${name}> does not hold plain text`); + } + return { text: body.text, next: expectClose(tokens, start + 2, name) }; +} + +function emptyValue(name: string): PlistValue { + if (name === "string") { + return { kind: "string", text: "" }; + } + if (name === "array") { + return { kind: "array", items: [] }; + } + if (name === "dict") { + return { kind: "dict", keys: new Map() }; + } + return { kind: "other" }; +} + +type ValueRead = { readonly value: PlistValue; readonly next: number }; + +function readValue(tokens: readonly Token[], start: number): ValueRead { + const token = tokens[start]; + if (token?.kind === "empty" && EMPTY_VALUE_TAGS.has(token.name)) { + return { value: emptyValue(token.name), next: start + 1 }; + } + if (token?.kind !== "open") { + throw new PlistShapeError("a dict key has no value"); + } + if (token.name === "dict") { + // Nested dicts are kept (EnvironmentVariables binds the service's home) + // and held to the same rules: an escaped or repeated key is unknown. + const nested = readDictBody(tokens, start + 1, true); + return { + value: { kind: "dict", keys: nested.keys }, + next: nested.next, + }; + } + if (token.name === "array") { + return readArray(tokens, start + 1); + } + if (!SCALAR_TAGS.has(token.name)) { + throw new PlistShapeError(`it has an unexpected <${token.name}> value`); + } + const scalar = readScalar(tokens, start, token.name); + const value: PlistValue = + token.name === "string" + ? { kind: "string", text: scalar.text } + : { kind: "other" }; + return { value, next: scalar.next }; +} + +function readArray(tokens: readonly Token[], start: number): ValueRead { + let i = start; + const items: PlistValue[] = []; + while (true) { + i = skipSpace(tokens, i); + const next = tokens[i]; + if (next?.kind === "close" && next.name === "array") { + return { value: { kind: "array", items }, next: i + 1 }; + } + const item = readValue(tokens, i); + items.push(item.value); + i = item.next; + } +} diff --git a/src/daemon/launchd-print.ts b/src/daemon/launchd-print.ts new file mode 100644 index 000000000..e2f123dc6 --- /dev/null +++ b/src/daemon/launchd-print.ts @@ -0,0 +1,233 @@ +/** + * Strict reading of `launchctl print gui//