Skip to content

feat(daemon): reinstall and restart a stale hackd service automatically - #854

Open
roodboi wants to merge 48 commits into
nextfrom
feat/daemon-upgrade-reconcile
Open

roodboi wants to merge 48 commits into
nextfrom
feat/daemon-upgrade-reconcile

Conversation

@roodboi

@roodboi roodboi commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Behavior

hack daemon start and hack daemon restart now bring an installed hackd login service up to the running CLI, so most upgrades no longer need a manual hack daemon install.

  • systemd (Linux), version mismatch: when the unit runs this CLI's executable (the same path, or a path that resolves to the same file by realpath or device/inode) and a running hackd reports another version, start restarts the service with systemctl --user restart. It first makes the loaded-file, program and stop-hook checks that stop makes.

  • systemd, stale content: start rewrites a stale unit only when all three hold:

    • it runs this CLI's executable;
    • its main file starts with Hack's managed header (# Managed by hack: written by \hack daemon install`...`), the owner policy;
    • its main file does not bind the service to a Hack home that the renderer would drop (HACK_HOME or HOME in Environment=, or any EnvironmentFile=).

    The rewrite keeps the program path as written, so a symlink stays a symlink, and keeps the unit enabled or disabled as it was. It uses install's program, PATH and unit-directory checks. When hackd is running, the unit's effective home is read again after the daemon-reload and before the restart.

  • Hand-written units: a unit without the header keeps the HACK-2052 behaviour. Start reports the drift with hack daemon install as the next step and does not rewrite it.

  • Never from inside the service: before any rewrite or restart of a running service, start checks whether it runs inside that service.

    • Linux compares /proc/self/cgroup with the unit's ControlGroup, read through the fakeable runner.
    • macOS checks whether the launchd job's pid is one of its ancestors.
    • From inside, a compatible daemon is kept (exit 0, action none, blocked: "inside-service") and a mismatched daemon is refused (exit 1). Nothing is stopped. If the check cannot be made, the caller counts as inside.
  • Refusals kept: a unit for another existing hack, an unreadable ExecStart, another Hack home, an isolated HACK_HOME without the opt-in, HACK_SYSTEMD_DISABLE, and HACK_DAEMON_SERVICE_READ_ONLY (which never writes or reloads).

  • launchd (macOS):

    • The program is read as launchd does: Program wins over ProgramArguments[0]. A plist whose program cannot be read (not a property list, a repeated key, a non-string value, an XML escape, a relative path) is "unknown-program" and never replaced automatically.
    • Only an established absence is retargeted: no program at all, a path that does not exist, or a compiled-binary virtual path (/$bunfs).
    • A refresh that fails (unload, write or load) is an error (exit 1, E_DAEMON_SERVICE), never "already running".
    • A stopped service whose plist runs another existing or unreadable program is not kickstarted, and hack daemon restart refuses before stopping anything. Earlier Hack retargeted such a plist instead; the next step is hack daemon install.
    • A plist that runs another existing hack is left alone ("other-program"), and a mismatched daemon under it is refused with hack daemon install.
    • A plist that runs this CLI's executable (same file) is refreshed, gaining the new managed marker comment, when it is Hack's. That means it carries the marker or its Label is Hack's (dance.hack.hackd), so plists written by earlier Hack versions (no marker) are refreshed on upgrade. It is only ever read at Hack's own LaunchAgents path. The older unbounded-output repair is kept. A plist with neither the marker nor Hack's label is reported as "unmanaged". launchd has no drop-ins, so the systemd header rule does not apply.
  • hack update: after a successful install, the update checks that a login service is installed and that this home's hackd answers on its socket. A live pid alone does not count. It then runs the new executable's hack daemon start --json with HACK_DAEMON_RECONCILE_ONLY=1. In that mode, start returns action none when no hackd of this home runs, so a daemon that stopped in the meantime is never started. The step is best effort: observation, run and parsing all report failures in the update result as daemon (with next_step: "hack daemon install") and never fail the update.

  • Shared logic: the pure decision is planDaemonReconcile in src/daemon/reconcile.ts. Its inputs are unit, programIsOurs, readOnly, running, restartPending, contentBlock, invokerInsideService and cliVersion; it returns rewrite, service, reasons and blocked.

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 --> [*]
Loading

JSON contract (--json on daemon start|stop|restart|install)

Only the envelope goes to stdout; human lines go to stderr. Exit codes are unchanged, and start --foreground --json is E_USAGE.

These commands follow the shared envelope rule from #835 (docs/reference/machine-readable-output.md): exactly ok and data at the top level, with the versioned schema inside data. They are schema-envelope in the JSON command inventory (src/cli/json-contracts.ts), and their schemas hack.daemon-start.v1, hack.daemon-stop.v1, hack.daemon-restart.v1 and hack.daemon-install.v1 are registered in MACHINE_SCHEMAS.

{ "ok": true, "data": {
  "schema": "hack.daemon-start.v1",
  "manager": "systemd",
  "action": "reinstalled",
  "stale": true,
  "stale_reasons": ["content"],
  "blocked": null,
  "cli_version": "4.3.0",
  "daemon": { "running": true, "pid": 1234, "version": "4.3.0", "compatible": true }
} }
  • manager: systemd | launchd | none
  • action: none | started | restarted | reinstalled | stopped | installed
  • 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 (a replaced daemon was started directly because the service could not start it)
  • Failure: { "ok": false, "error": { "code": "E_DAEMON_SERVICE", "message": <last warning/error printed>, "detail": <same data> } }. Access refusals keep E_PERMISSION.
  • E_DAEMON_SERVICE is a new registry code, and the generated hack-diagnose reference is regenerated.

Merge of next at 081a526 (merge commit 417f741)

Review round 37 fix: repair only from explicit install (commit 2f26965)

  • Automatic rewrites (rewriteUnitForPlan into installSystemdUnit) still passed repair: true inside the shared installer. A directory that became writable after the earlier gate was chmod-ed closed, and the run went on with whatever was planted in that window.
  • installSystemdUnit (src/daemon/systemd.ts) takes repair, defaulting to false. It threads it into ensureOwnerWritableOnlyDirectories, the drop-in check and the unit-file check.
    • Without it, a writable unit directory, or a unit file another account could have written (which explicit install removes and rewrites), is refused with untrusted.
    • The mode is unchanged, and nothing is written, reloaded or restarted.
  • rewriteUnitForPlan records blocked: "untrusted-unit" when the installer refuses for trust.
  • Audit of repair: true in src: the only caller is explicit install, installDaemonSystemdService (src/commands/daemon-systemd.ts). It is reached from hack daemon install, and from hack doctor --fix only after the user explicitly confirms the install.
    • src/commands/global.ts offers hack daemon install for launchd on macOS, which does not use the systemd installer.
    • src/commands/project.ts has an unrelated repair option.
    • No automatic path reaches repair: true.
  • Tests:
    • a unit directory made group-writable after the gate (during the first ownership proof), before the rewrite's install: untrusted-unit, mode still 0775, the stale unit unchanged, no daemon-reload or restart; explicit install then repairs it;
    • installSystemdUnit without repair refuses a writable unit directory with nothing written or reloaded; with repair it secures and writes.
      Both fail with repair forced on.

Owner decision: hack daemon stop is not blocked by those checks (commit 6b3a292)

  • A stop loads and runs no new unit code, and Hack never reloads to stop. Blocking it would keep the user from shutting hackd down exactly when something looks wrong.
  • checkSystemdStopHooks (src/daemon/systemd.ts) no longer runs checkSystemdUnitTrusted plus checkSystemdUnitLoadPaths. Those now refuse writable unit and load directories and hackd.service.wants/.requires/.upholds.
  • Instead it checks:
    • the unit file itself (checkUnitFileItself): a regular file this user owns that others cannot write; directories are neither judged nor repaired;
    • as before, what systemd has loaded: the fragment and loaded drop-ins (checkLoadedUnitFiles), and no ExecStop or ExecStopPost hooks.
  • Stop's ownership and home checks are unchanged (decideUnitStop, main-process proof, fresh re-read before the stop), and stop never runs daemon-reload.
  • Start, restart, the pre-stop check before a replacement, the post-update reconcile, the automatic gate and install keep the full refusal. Restart also calls checkSystemdStopHooks, but it runs the full checkSystemdUnitTrusted and checkSystemdLoadedUnitPaths just before it.
  • Tests:
    • with a foreign hackd.service.wants/payload.service link, stop stops this home's proven daemon (systemctl --user stop, no daemon-reload), while the automatic restart and a later start refuse;
    • with a group-writable unit directory, stop stops it without a reload, and the directory's mode is unchanged (0775).
      Both fail with the old broad stop check restored.

Codex security finding: no automatic repair, and relationship directories (commit 2088b96)

  • On the automatic paths, checkSystemdUnitFile chmod-ed (repaired) a writable user-unit directory before the load-path check ran. No check looked at hackd.service.wants/, .requires/ or .upholds/.
    • So another account that could write the directory could pre-plant payload.service plus a .wants link.
    • The directory then looked trusted, daemon-reload loaded both units, and the restart ran the payload.
  • What next does today: these checks (checkSystemdUnitFile, checkSystemdUnitDropIns, through checkOwnerWritableOnlyDirectory) removed group and other write from owned directories on every path that used them: install, start and stop.
  • (1) No automatic repair. checkOwnerControlledPath repairs an owned directory only with repair: true.
    • Only explicit hack daemon install passes it: securing its own directories, and the drop-in and unit-file checks right after.
    • Start, stop, restart, the pre-stop check and the automatic gate refuse a group- or other-writable unit or drop-in directory with blocked: "untrusted-unit", and leave its mode unchanged.
  • (2) Relationship directories. checkUnitLoadDirectory now checks hackd.service.wants/, .requires/ and .upholds/ in every user-unit load directory.
    • Each must be absent, or an empty directory owned by this user or root that others cannot write. A link, any entry, or a writable one is refused.
    • Hack's renderer and install create none of them (systemctl enable links into default.target.wants/).
    • It runs inside checkSystemdUnitLoadPaths, so before every reload, start and restart (the automatic gate, checkSystemdLoadedUnitPaths), and in stop and install.
  • Tests (tests/daemon-systemd.test.ts):
    • an automatic restart and the pre-stop check refuse a group-writable unit directory, and its mode stays 0775, with no reload or restart;
    • a hackd.service.wants/payload.service link stops the automatic restart, the pre-stop check and a stopped start, with no reload, start or restart; .requires and .upholds are refused the same way;
    • an absent relationship directory proceeds; so does an empty, safe one; a writable empty one is refused.
      The existing start test now asserts the refusal and unchanged mode, then that install repairs it and start proceeds. The drop-in mask test asserts the refusal without repair, and the repair with repair: true. Each test fails with its fix disabled.

Review round 35 fix: ps and lsof in the process proofs (commit 7f489a6)

  • An automatic replacement still reached PATH lookups. The orphan sweep's process table and the ownership proof's process births spawned bare ps, and the socket-owner probe found lsof on PATH.
  • resolveSystemTool (src/lib/system-tool.ts) returns a fixed absolute path and never consults PATH:
    • macOS: /bin/ps, /usr/sbin/lsof;
    • Linux: the first existing of /bin/ps or /usr/bin/ps, and of /usr/bin/lsof or /usr/sbin/lsof, accepted only if checkReplaceableChain passes (the chain check checkServiceExecutableTrusted runs);
    • none qualifying returns null.
  • Every caller fails closed:
    • listProcessTable (orphan sweep): no trusted ps makes the scan unknown. sweepOrphanDaemons then stops nothing, and the start does not go on as if no orphan held the socket;
    • listOpenUnixSockets and the macOS socket-owner probe: no trusted lsof leaves socket owners unknown (null), so ownership is not proven;
    • readObservedProcesses (ownership-proof births on macOS, and its non-daemon callers): no trusted ps leaves the snapshot unknown (null), as a failed ps already did.
  • Callers affected:
    • daemon: findOrphanDaemonProcesses → sweepOrphanDaemons (start, restart, replacements) and defaultDaemonProcessProbe (birth, socketOwners), used by proveOwnedDaemonProcess for stop, the replacement and the restart ownership proofs;
    • non-daemon readers of readObservedProcesses: src/commands/host-ps.ts (hack host ps), src/commands/usage.ts (hack usage), and the host-command observation in the same module. They now use the fixed path too; their suites pass.
  • Test seam: useSystemToolForTests, test-only. No existing test fakes these functions' ps or lsof through PATH. The two tests that plant a ps stub exercise other code, and their suites pass unchanged.
  • Not in scope: other ps uses elsewhere in Hack (compose, crash capture, lifecycle) are outside this PR and these daemon paths.
  • Tests (tests/system-tool.test.ts):
    • with ps and lsof stubs planted first on PATH, the orphan sweep, a process snapshot, and the ownership probe as macOS runs it (births through ps, socket owners through lsof) spawn only absolute paths outside the planted directory, and the planted stubs never run;
    • resolveSystemTool picks the fixed candidates in order, skips an untrusted one, and returns null when none qualifies;
    • with no candidate, the orphan scan is unknown.
      The main test fails with a bare ps put back in either the sweep or the snapshot read.

Codex security finding: launchctl and ps by absolute path (commit 3e6461c)

  • runLaunchctl spawned bare launchctl through the inherited PATH. A planted launchctl in an earlier PATH directory would run as the user after hack update. That was already true on next; this PR makes it reachable automatically.
  • runLaunchctl is the only place the launchd module runs launchctl. It now runs /bin/launchctl (LAUNCHCTL_PATH, launchctlArgv in src/daemon/launchd.ts), so every launchd operation is covered.
  • The macOS ancestry check already ran /bin/ps. It is now named (PS_PATH, psParentArgv in src/daemon/process-ancestry.ts) and pinned by a test.
  • Test seam: useLaunchctlBinaryForTests. It is called only by the test preload tests/helpers/launchctl-stub-preload.ts, which reads HACK_TEST_STUB_LAUNCHCTL. No production code reads an environment variable for the binary. The launchd driver tests load it after the darwin preload.
    • Without it, 32 of those tests fail on this Linux host: a PATH stub no longer reaches runLaunchctl, which is the fix itself.
  • Spawns of bare tool names in the files this PR touches:
    • launchctl: changed, as above;
    • ps: already absolute;
    • systemctl, loginctl and getent go through the systemd runner. It resolves them on PATH but refuses one whose path another account could replace (checkReplaceableChain, HACK-2052). Unchanged;
    • the replacement spawn (executingDaemonArgv) and the post-update re-exec already run absolute, verified paths.
    • launchctl setenv in src/commands/global.ts (TLS trust) still runs by name, but this PR does not touch that file or its path.
  • The e2e harness comment now says its PATH launchctl shim no longer sees the launchd module's calls. For those, HACK_LAUNCHD_DISABLE=1, which is checked before any spawn, is the guard.
  • Tests: launchctlArgv starts with /bin/launchctl; psParentArgv starts with /bin/ps.

Codex findings: replacement spawn and post-update re-exec (commits 81c4372, c0f95fc)

Replacement spawn (81c4372). When a newer CLI ran by absolute path while an older hack came first on PATH, a replacement's direct fallback spawned the PATH-first hack. The start counted as successful on pid and socket alone, so the post-update reconcile reported an upgrade that had not happened.

  • A replacement's direct start runs executingDaemonArgv (src/daemon/executing-cli.ts): the executing compiled binary, or the same runtime and entry point for a source invocation. It never runs the PATH-first hack.
    • This covers startReplacementDaemon's fallback and the direct start in startStoppedDaemon after a replaced daemon: the start replacement, restart and the reconcile-only replacement.
    • Ordinary starts with nothing to replace keep their existing spawn.
  • startReplacementDaemon now requires verifyStarted. After any replacement start, through the service or directly, the daemon must answer on this home's socket and report this CLI's version (probeDaemonApi compatibility, describeUnverifiedReplacement).
    • Otherwise: exit 1, action none, E_DAEMON_SERVICE, never restarted. The update hook then records daemon.status: "failed".
  • Tests:
    • a replacement whose started hackd reports an older version fails through both the service and the direct path;
    • the replacement argv is the executing binary (and the source runtime plus entry point), not the PATH-first hack;
    • the version-verification test fails with the check disabled.

Post-update re-exec (c0f95fc). The post-update hook re-executed binaryPath after publishRelease. With an install directory another account can write (a custom HACK_INSTALL_BIN), a payload could be renamed over it before the spawn.

  • publishRelease returns installedBinary: the device and inode of the published file (the rename keeps the staged inode) and the sha256 it already records in the backup manifest. update.ts passes it to the hook.
  • verifyInstalledBinary (src/commands/daemon-upgrade.ts) runs right before the spawn. It requires:
    • checkServiceExecutableTrusted on the path;
    • no link at the final component;
    • the same device, inode and sha256.
      Any failure runs nothing and records daemon.status: "failed" with hack daemon start as the next step. The update itself still succeeds.
  • Not done: spawning through a verified descriptor. Bun has no portable fexecve, and macOS cannot exec through /dev/fd. The trust check closes the window between the identity check and the spawn for other accounts.
  • Tests:
    • a world-writable install directory runs nothing;
    • a binary replaced after publish (another inode) runs nothing;
    • a link at the final component runs nothing;
    • the trusted, unchanged binary runs;
    • publishRelease's returned identity matches the published file.
      The hook tests fail with the check disabled.

Review round 32 fix (commits 7ffaf75, 3a49b04, 91cc203)

  • The automatic reload checked the plist text, then bootstrapped whatever file was at the plist path. Someone able to replace a group-writable plist or its directory could swap the command in between.
  • (a) Trust: checkLaunchdPlistTrusted (src/daemon/launchd.ts) requires:
    • the plist is a regular file (not a link), owned by this user, with no group or other write;
    • ~/Library/LaunchAgents does not let others add or replace entries;
    • no directory above it is replaceable by another account (checkReplaceableChain).
      Nothing is repaired. It runs before any unload, write or bootstrap: install (explicit, implicit autostart, refresh, retarget), the reload before a kickstart, and the check before a running daemon is stopped. A failure is blocked: "untrusted-unit" (repair result untrusted-plist), consistent with the documented meaning of untrusted-unit.
  • (b) Byte binding, not rewriting. I chose to bind the bytes rather than have the reload write the validated bytes itself. Rewriting would bootstrap over a replacement instead of refusing it, and would silently overwrite a plist the user just edited.
    • The reload reads the plist through an O_NOFOLLOW descriptor and requires exactly the checked bytes.
    • It checks that the file is still the same (bytes, device and inode) before the unload.
    • It reads the file again the same way right before launchctl bootstrap.
    • With (a) in place, no other account can change the file in between, and the binding catches any change anyway.
  • (c) Every install, refresh and retarget now writes a fresh file renamed onto the path (writeTextFileAtomicIfChanged with noFollowTarget, which refuses a link there) and binds its bootstrap to those bytes the same way. The reload path is bound as above.
  • Tests (through the macOS driver):
    • a plist replaced between its check and the reload (the stub swaps it on bootout) is neither bootstrapped nor kickstarted; a positive control shows the same scenario otherwise reaches bootout and bootstrap;
    • a group-writable plist, or a group-writable ~/Library/LaunchAgents, is refused before any unload;
    • an install into a group-writable ~/Library/LaunchAgents loads nothing.
      The swap and trust tests fail with the binding or the trust check disabled.
  • In-process tests now use a real, safe launchd home instead of /nonexistent-home.

Review round 31 fixes (commit 13a4809)

  1. Explicit PATH.
    • Automatic starts (kickstart, reload, bootstrap) now require the checked plist to set its own PATH. launchd's default or inherited PATH is never assumed, and the default-PATH fallback is gone.
    • loadedJobMatchesPlist requires that same explicit PATH in the loaded job's own environment, so a job without its own PATH never matches.
    • Hack has always rendered PATH and the exact command: the first launchd renderer already did. A Hack plist without them is refreshed by the normal checked repair, which writes both. Any other such plist is refused with rewrite-refused and the hack daemon install step.
  2. Exact command, checked before any unload or bootstrap.
    • checkLaunchdJobBeforeStart now validates the plist's complete ProgramArguments as exactly <program> daemon start --foreground, with Program taking precedence as launchd does.
    • It runs before any unload, bootstrap or kickstart, so a reload under RunAtLoad or KeepAlive cannot run custom arguments. The new parseLaunchdPlistCommand reads the full argument vector.
  3. Availability (owner decision).
    • Reload eligibility is read fresh, right before the bootout, through an isRunning callback. A job that started running meanwhile is not booted out, and an unreadable status counts as running.
    • After a successful reload of the fully checked plist, an unparseable re-read is accepted and the kickstart proceeds. A positive mismatch still refuses.
    • Before any reload, an unreadable loaded job on a running service still refuses.
    • docs/architecture.md documents real launchctl print parsing as an unverified compatibility point, with this as the fallback.
  • Tests:
    • no own PATH, extra arguments, or a Program that differs from the first argument: each refused before anything changes, with start unloading, reloading and kickstarting nothing;
    • autostart with a custom-argument plist on a stopped job with a stale loaded configuration gives no reload and no kickstart;
    • a Hack plist without PATH is refreshed and gains one;
    • an unknown re-read after our reload kickstarts; a mismatched re-read after a reload refuses; a job that became running between the check and the reload is not booted out.
      The argument, PATH and fresh-running checks each fail their tests when disabled.

Review round 30 fixes (commit 6792a61)

  1. A kickstart runs the configuration launchd has loaded, not the plist on disk. A loaded job could keep an unsafe PATH or program after the plist was checked.
    • The new src/daemon/launchd-print.ts (parseLaunchdPrintJob) reads the loaded job strictly from launchctl print gui/<uid>/dance.hack.hackd: program, the arguments list and the job's own environment PATH (not the inherited or default environment). Anything malformed, repeated or unbalanced is unknown.
    • verifyLaunchdJobBeforeKickstart (src/daemon/launchd.ts) runs before every automatic kickstart: start, restart, the post-update reconcile, a stopped-service start and autostart. It checks the plist on disk, then requires the loaded job to run exactly that plist: the same program, Hack's arguments and the same PATH.
    • When the loaded job differs, or can't be read: a stopped job is reloaded (bootout plus bootstrap) from the checked plist and read again; a running job is refused with rewrite-refused, because a reload would stop hackd. An unverified loaded configuration is never kickstarted.
    • The check before a running daemon is stopped refuses a loaded job it can't read.
  2. An implicit install (autostart with no plist yet) bootstrapped before the program was checked.
    • installLaunchdService now runs checkServiceExecutableTrusted on the program it writes before any unload, write or bootstrap (unsafeProgram). Every caller shares it: explicit install, the implicit autostart install, refresh and retarget.
    • Durability is not required at install, so a normal first install from any trusted location still works. A retarget still requires durability through checkLaunchdRetargetTarget.
  • Tests:
    • parser (tests/launchd-print.test.ts): real-looking launchctl print output with inherited, default and job environments and nested event triggers; the strict refusals;
    • a safe plist with an unsafe loaded PATH: a running job is neither reloaded nor kickstarted; a stopped job is reloaded and, if launchd still reports the unsafe configuration, not kickstarted; an unreadable loaded job likewise; a reload that fixes it starts; the pre-stop check refuses an unreadable loaded job;
    • an explicit install and autostart's implicit install of a hack in a group-writable directory: no bootstrap.
      Each fails with its fix disabled.
  • The stub launchctl print now emits a service block. The autostart positive control has a matching loaded job, and expects the extra print that reads it.

Merge of next at 34b5053 (merge commit 240dd08)

Codex security finding: check the program and PATH before every automatic start (commit 8610528)

  • With a current plist, an incompatible daemon's replacement skipped the program and PATH checks. So the post-update reconcile stopped the old daemon and kickstarted launchd, and hackd ran bare docker through a PATH directory another account could write.
  • checkLaunchdJobBeforeStart (src/daemon/launchd.ts) checks the plist launchd will start:
    • its program, with checkServiceExecutableTrusted;
    • every directory of the PATH it gives hackd (its own PATH, or launchd's default when it sets none), with checkServicePathDirectories;
    • an unreadable program or PATH is refused.
  • It runs as the last step before every automatic kickstart:
    • start, restart, the post-update reconcile and a stopped-service start, through startViaLaunchdIfLoaded;
    • autostart (ensureLaunchdDaemonReady), which then falls back to hack daemon start and gets the same refusal.
      A refusal kickstarts nothing, with blocked: "rewrite-refused" and the hack daemon install step.
  • The check before a running daemon is stopped makes the same check for a current plist (start-refused, reported as rewrite-refused), so nothing is stopped for a start that would be refused.
  • systemd:
    • a stopped-service start already ran checkSystemdLoadedUnitPaths (loaded programs and effective PATH) right before systemctl start;
    • an automatic restart's last check (refuseUntrustedProgramLast) now covers the effective PATH too.
  • Tests:
    • a current plist with an unsafe PATH, in-process: an incompatible running daemon is refused before any stop; restart's check before its stop refuses; a stopped service is not kickstarted; with the PATH made safe again, the stopped service starts;
    • autostart with an unsafe plist PATH: no kickstart, and it falls back to hack daemon start;
    • systemd: a service PATH directory that becomes writable during the final proof gives no restart.
      Each fails with its check disabled.
  • The autostart positive control and two in-process tests now use a real plist whose program and PATH are the test's own. The full focused set passed with the host's system directories simulated as unsafe, so no test depends on the host.

Codex security finding: check the launchd PATH (commit 7b3fe8a)

  • A launchd refresh rewrote and reloaded the job after checking only the hack executable. hackd runs helpers such as docker by bare name through the plist's PATH, so a PATH directory another account can write gave code execution as this user.
  • installLaunchdService (src/daemon/launchd.ts) now checks every directory of the PATH the rendered plist gives hackd before it unloads or writes anything. Those directories are /usr/local/bin, /opt/homebrew/bin, /usr/bin, /bin, ~/.bun/bin and ~/.hack/bin. The check is checkLaunchdServicePath, which uses systemd's checkServicePathDirectories with no administrator-directory exception. A failure is reported as unsafePath.
  • Explicit install: before this change, hack daemon install on macOS had no PATH check. It now refuses an unsafe PATH the same way, so explicit install and the automatic rewrite agree.
  • Automatic refresh and retarget: an unsafe PATH maps to refresh-refused or retarget-refused (blocked: "rewrite-refused", hack daemon install step). Nothing is written, unloaded or loaded.
  • The check before a running daemon is stopped (rewriteTargetIsFit) makes the program and PATH checks for a refresh or retarget the start would run.
  • The rendered PATH is built from one list (renderedLaunchdPathDirectories). The system directories have a test-only in-process seam, as systemd's do (HACK-2142). The test driver points it at a fixture directory, so CI runner images with writable system directories don't decide these tests.
  • Tests (through the macOS driver):
    • a refresh whose PATH has a group-writable directory gives refresh-refused, no bootout or bootstrap, and an unchanged plist;
    • a retarget whose ~/.hack/bin is world-writable gives retarget-refused, with nothing changed;
    • a refresh with a safe PATH still refreshes;
    • an explicit install with an unsafe PATH gives unsafePath and no bootstrap.
      The three refusal tests fail with the check disabled.
  • I ran the 27-file focused set with the check failing whenever the seam is not set, simulating an unsafe host. Only one in-process test depended on the host's directories, and it now uses the seam.

Review round 27 fix (commit 080a785)

  • The last check before an automatic systemd restart validated only the main file's program. A retained drop-in can run another pathname for the same file (a hard link, for example), which passes the identity and earlier trust checks. If that path became replaceable during the final ownership proof, the restart still went through it.
  • refuseUntrustedProgramLast (src/commands/daemon-systemd.ts) now reads the single loaded ExecStart (readSystemdLoadedProgram). It runs checkServiceExecutableTrusted on that exact pathname, symlinks and every directory above it included, as well as on the main file's program.
  • It fails closed. A loaded program that cannot be read, or is not exactly one entry, gives blocked: "unproven-owner". An untrusted one gives rewrite-refused. Either way nothing is restarted.
  • Tests:
    • a drop-in alias (another path, same inode as the CLI) whose directory becomes group-writable during the final proof is not restarted; with the directory safe, the same restart goes on;
    • two loaded ExecStart entries at the last check are not restarted (unproven-owner).
      Both fail with the fix disabled.

Codex security finding: check the kept program before every automatic rewrite (commit 26d9fbd)

  • On macOS, a content refresh of a stale plist kept the plist's program after same-file checks only. The full target checks ran only for a missing-program retarget, and isSameExecutableFile accepted equal path strings. Someone able to replace that pathname would have launchd boot out the safe daemon and bootstrap their program as this user.
  • repairLaunchdProgramIfInvalid (src/daemon/launchd.ts) now runs checkLaunchdRetargetTarget on the program the rewritten plist will run, for a refresh and a retarget alike, as its last step before the write, bootout and bootstrap. That check rejects a source file or bun script, requires a durable location, and runs checkServiceExecutableTrusted along the whole path.
  • A refused refresh returns the new result refresh-refused, reported as blocked: "rewrite-refused" with the hack daemon install step. Nothing is written, unloaded or loaded.
  • The check before a running daemon is stopped (refreshBlocksReplacement) refuses a refresh the start would run for an unfit program, so nothing is stopped.
  • isSameExecutableFile always resolves both paths and compares device and inode. Equal strings never short-circuit.
  • systemd: the content rewrite already checks the kept program (checkServiceExecutableTrusted plus the service PATH) inside rewriteUnitForPlan, right before installSystemdUnit. An automatic restart now checks the unit's program once more as its very last step, after the ownership proof and loaded-program read (refuseUntrustedProgramLast, blocked: "rewrite-refused").
  • Tests:
    • a refresh whose kept program's directory is group-writable gives refresh-refused, no bootout or bootstrap, and an unchanged plist;
    • equal path strings with a different device or inode are not the same file, and a missing path is not the same file as itself;
    • a restart whose program directory becomes group-writable during the final ownership proof gives no restart and rewrite-refused.
      Each fails with its fix disabled. The launchd fixture's temp-dir hack now counts as durable by default through the existing driver seam; the temporary-location test opts out.

Merge of next at 345dc0d, with the #835 envelope rule (merge commit af7957d)

  • Conflict: only the generated plugins/hack/skills/hack-diagnose/references/error-codes.md (content revision line). It was regenerated with bun run generate:agent-plugins: next's rows plus E_DAEMON_SERVICE.
  • Envelope decision: this PR's output already had fix(cli)!: standardize HACK-2218 machine output contracts #835's shape. Both have exactly ok and data, the schema inside data, and {ok:false,error:{code,message,detail}} on failure. The four commands are recorded as schema-envelope, and their schemas are added to MACHINE_SCHEMAS and SCHEMA_JSON_VARIANTS.
  • The one visible change: the schema id moves from hack.daemon-<command>/v1 to the registry's dotted form hack.daemon-<command>.v1. buildDaemonCommandData takes it from the registry (DAEMON_COMMAND_SCHEMAS), and the post-update parser checks the same constant. Fields, enums, error code and exit codes are unchanged.
  • Regenerated docs/reference/machine-readable-output.md, which now has four daemon rows. The CLI reference had no diff. docs/architecture.md links the shared rule, and the command contracts use the new ids.

Review round 24 fix (commit effa1dc)

  • startViaSystemdIfInstalled refused an untrusted main unit (another account's, writable by others, or a link) before reaching the trust gate, and did not record why, so daemon start --json reported blocked: null. Both refusals now record manager: "systemd", action: "none" and blocked: "untrusted-unit" through one helper (recordUntrustedUnit). The early message ends with the hack daemon install step.
  • The early check is still only the unit-file check, ahead of the home check, so another home's unit is handled as before.
  • New test: a stopped start with an untrusted main unit (another uid, then a symlinked unit) gives JSON data with manager: systemd, action: none, blocked: untrusted-unit, no systemctl mutations, and the file unchanged. It fails without the fix. The round-11 test that pinned an empty trace for this refusal now expects the recorded reason, and still checks that systemd was not asked to start.

Codex security finding: unit trust before any change (commit 01b83fd)

  • On the running path, a unit naming a missing program reached the retarget before the trust check. The retarget keeps every other directive and rewrites the file as this user, so the check after it passed for text another account planted (for example a Wants= of its own unit), and the restart then ran it.
  • New checkSystemdUnitTrustedBeforeChange (src/daemon/systemd.ts) runs the checks install and enable use: checkSystemdUnitTrusted (unit file owner, mode and no link, the directories above it, drop-in directories), every user-unit load directory, and the loaded fragment and drop-ins that a rewrite keeps. It does not judge the loaded program, because a retarget replaces a missing one and the check just before the restart judges what is loaded then.
  • It runs before the unit is read to keep its directives, rewritten, retargeted or reloaded, and before the restart:
    • on the running path, before the restart marker, rewrite, pending reload and restart;
    • on the stopped start, before a rewrite or a pending reload;
    • in refuseSystemdStartBeforeReplacing, so a running daemon is never stopped for an untrusted unit.
  • A failure writes, reloads, stops and restarts nothing. It refuses with the new closed blocked reason untrusted-unit and hack daemon install as the next step. The reason is added to the enum, docs/architecture.md and the command contracts. The preflight's existing unit-file trust refusal now reports untrusted-unit instead of unproven-owner.
  • Managed-header rule unchanged.
  • Tests (tests/daemon-systemd.test.ts), each with a missing program and a planted Wants=:
    • running daemon, unit file owned by another uid (modelled with the trustUid seam): no write, no systemctl mutation, no marker, untrusted-unit; the trusted control retargets and reloads;
    • running daemon, group-writable user-unit load directory: the same;
    • stopped start, group-writable load directory: no write, no reload, untrusted-unit;
    • pre-stop preflight, foreign uid and group-writable load directory: untrusted-unit with the install step, no mutations.
      The CLI test for a world-writable unit under daemon restart now expects untrusted-unit; it still checks that the running daemon is not stopped.
  • Mutation check: with the running-path gate disabled, both running tests fail. With the stopped-start and preflight gates disabled, those two tests fail.

CI fix: spawn env isolation (commit 62ae3e4)

  • The compiled-binary systemd CLI test ran bun build without an env, so it inherited the runner's environment and HOME. It now spawns with privateCliEnv({ root }), so tests/cli-spawn-env-isolation.test.ts passes without an allowlist entry.

Review round 19 fix (commit 356ac41)

  • Retarget eligibility is checked before stopping an incompatible launchd daemon. launchdJobBlocksReplacement (src/commands/daemon-launchd.ts) is the pre-stop check shared by start's checkRunningLaunchdDaemon and restart's refuseLaunchdBeforeReplacing. When the plist's program is missing or virtual, it now applies the retarget that would follow the stop, using the same helpers as the repair:
    • the target must pass checkLaunchdRetargetTarget, or it refuses as rewrite-refused;
    • the executing CLI must be a compiled binary that is that target (refuseRunningRewrite, now exported from src/daemon/launchd.ts): unproven-owner from source, other-program for another binary.
    • It refuses before any stop, with hack daemon install as the next step.
  • Tests: for an incompatible running daemon whose plist names a missing program, a source run, another compiled binary and an unfit target are each refused, with nothing repaired or kickstarted. The compiled CLI that is the target proceeds. Restart's pre-stop check refuses the source run the same way.

Review round 17 fixes (commit 9b4b830)

Settled policy: a source invocation may repair a missing program on a stopped service, but nothing may unload or restart a running daemon unless the executing CLI is eligible.

  1. launchd rewrites of a running daemon are gated. repairLaunchdProgramIfInvalid (src/daemon/launchd.ts) runs refuseRunningRewrite whenever hackd runs. A running daemon is one that startViaLaunchdIfLoaded found running and compatible (daemonRunning), or a job launchd reports running.
    • The rewrite needs a compiled executing CLI that is the same file as the program the plist will run.
    • Otherwise nothing is booted out, bootstrapped or kickstarted, and the daemon is kept: unproven-owner from source, other-program for another binary, with hack daemon install as the next step.
    • A stopped job is still repaired from any CLI.
    • Tests through the macOS test driver: from source, with a running daemon and a plist naming a missing program, nothing is booted out, bootstrapped or kickstarted and the plist is unchanged. When the executing CLI is the retarget target, the plist is repaired (bootout and bootstrap). An in-process test checks that a compatible running daemon is kickstarted only after an actual repair.
  2. The launchd retarget target is validated. checkLaunchdRetargetTarget applies the systemd retarget checks.
    • It rejects a source file (.ts, .js...) and a #! script run by bun.
    • The target must be durable (assessExecutableDurability) and trusted (checkServiceExecutableTrusted).
    • A failure returns retarget-refused, and start reports blocked: "rewrite-refused". The retarget now writes the validated target explicitly.
    • Tests: unit cases for a source file, a bun script, a non-durable path, and an accepted installed binary. Through the driver, a retarget to a hack in a temporary location is refused with no launchctl calls.

Codex finding: "this CLI" is the executing binary (commit d28eddb)

  • Ownership is judged by the executing CLI, not the hack on PATH. resolveExecutingCliPath (src/daemon/executing-cli.ts) returns process.execPath for a compiled hack. A source invocation (bun index.ts) gives null, which is never eligible for an automatic restart or refresh.
  • Where it is used:
    • systemd (src/commands/daemon-systemd.ts): unitRunsExpected, loadedProgramIsExpected (the early gate and the final check before restart) and describeSystemdIncompatibleOwner, through resolveExecutingProgram and a new executingProgram hook.
    • launchd (src/daemon/launchd.ts): readLaunchdProgramState, which drives refresh eligibility, the restart and stopped-start refusals and the running-mismatch refusal.
    • The PATH-selected hack remains only the install and retarget target.
  • Effect: a newer CLI run by path, as the post-update hook runs it, no longer treats an older hack first on PATH as itself. A service running another program with a mismatched daemon is refused as other-program with a hack daemon install next step, without a restart.
  • Tests:
    • The executing binary differs from the PATH-selected hack and the unit runs the PATH one: a mismatched daemon is refused as other-program, and a compatible one with a pending marker is kept as other-program. A source invocation (null) never restarts.
    • Unit tests: compiled vs source detection, and restart's launchd home-binding check in process.
    • The end-to-end Linux rewrite-and-restart CLI test now runs a compiled hack, because a source shim is never eligible.
    • The macOS driver pins the executing CLI for its repair cases.
    • The darwin CLI restart test runs from source, so it may be refused as other-program before home-binding; either way nothing is stopped.

Review round 15 fix (commit 3b59c56)

  • A pending reload is retried before the loaded program is judged. In reconcileRunningSystemdDaemon (src/commands/daemon-systemd.ts), systemd's loaded copy is stale while NeedDaemonReload=yes is reported for a unit file that runs this CLI (a repair wrote it, but its daemon-reload failed).
    • Outside read-only mode, the early loaded-program gate no longer judges that copy.
    • The restart path retries the reload (completeReloadBeforeRestart, with the post-reload home recheck).
    • Then refuseUnexpectedLoadedProgram judges the newly loaded ExecStart just before the restart.
    • A later start therefore finishes a retarget whose reload failed instead of keeping the stale program forever.
  • Test, two runs: a running daemon of this version and a unit naming a deleted program.
    • Run 1 retargets the unit; its reload fails, so there is no restart and the marker is kept.
    • Run 2 reloads first. The loaded ExecStart is now this CLI, so it restarts and clears the marker.

Review round 14 fix (commit fe38f27)

  • The loaded-executable check gates every automatic restart. Changes are in src/commands/daemon-systemd.ts.
    • gateRestartOnLoadedProgram applies after planning, whatever asked for the restart: a pending-restart marker, a stale-content plan or a version mismatch.
      • A loaded ExecStart that is another hack keeps a compatible daemon (blocked: "other-program"), or refuses a mismatched one.
      • An unreadable one does the same with blocked: "unproven-owner".
      • The restart-pending marker stays.
    • A program retarget is the one exception before the rewrite, because the loaded copy still names the missing program. refuseUnexpectedLoadedProgram re-reads the loaded ExecStart after any rewrite or reload and just before every automatic restart. A retained drop-in that still selects another program stops the restart there, with the marker kept.
  • Tests: a compatible daemon with a pending marker and a drop-in for another executable gets no restart (other-program, marker kept); the same with an unreadable ExecStart gets unproven-owner. A missing main program with a retained ExecStart drop-in override is retargeted and reloaded, but not restarted (other-program, marker kept).

Codex findings: restart ordering and loaded ExecStart (commit fa4b583)

  • A: restart is checked before its stop. restartDaemon (src/commands/daemon.ts) runs refuseRestartBeforeStop before stopping a running hackd. On macOS that is refuseLaunchdBeforeReplacing (src/commands/daemon-launchd.ts): the loaded job must not bind another home and must be this home's. On Linux it is refuseSystemdStartBeforeReplacing. A refusal exits 1 with E_DAEMON_SERVICE and the matching blocked reason, and nothing is stopped. The start after the stop now passes alreadyReplaced, so it goes through startReplacementDaemon with the same idle-gated direct fallback. Tests:
    • macOS (darwin-preloaded CLI): a live process in the pid file with its socket, and a plist binding another HACK_HOME. restart --json is refused as home-binding; the process lives, its pid file is kept, only launchctl print ran, and the plist is unchanged.
    • Linux (fake systemctl CLI): an installed, running service whose unit file others can write. restart --json is refused as unproven-owner; the daemon lives and no stop was issued.
  • B: automatic restart follows the loaded ExecStart. reconcileRunningSystemdDaemon (src/commands/daemon-systemd.ts) now also requires the program systemd has loaded to be this CLI's executable by file identity. That is the single loaded ExecStart, drop-ins included, read by readSystemdLoadedProgram (src/daemon/systemd.ts) from systemctl --user show. A drop-in running another hack keeps the daemon (blocked: "other-program"); an unreadable or ambiguous ExecStart keeps it too (blocked: "unproven-owner"). Tests: a drop-in ExecStart for another executable with stale main content and a compatible daemon gives no restart, no reload, an unchanged unit and the daemon kept; two loaded ExecStart entries give unproven-owner and no restart.

Review round 12 fix (commit cb3aafa)

  • The launchd idle gate is simplified (owner decision). readLaunchdManagerState (src/daemon/launchd.ts) now always queries launchctl print for Hack's label, whether or not the plist file exists, since moving or deleting the file does not unload the job.
    • It is idle only on the confirmed not-found answer: exit 113 with "Could not find service".
    • Every loaded job, whatever its plist file says, is unknown, and so is every other result. That covers loaded config differing from the file, StartInterval, StartCalendarInterval and WatchPaths.
    • The missing-plist shortcut and the on-disk KeepAlive/RunAtLoad reading are gone; parseLaunchdPlistRetry and the parser's boolean value kind are removed.
    • A process that may not use launchd (isolated HACK_HOME, HACK_LAUNCHD_DISABLE) cannot query it and never started a launchd job, so it still counts as idle. This is documented in the code.
  • Tests through the macOS test driver (new manager-state mode): a missing plist file while the job is still loaded is unknown; a loaded, stopped job whose plist on disk has no KeepAlive is unknown; launchd's confirmed not-found answer is idle with or without the plist file. Unit tests cover the classifier cases.

Review round 11 fixes (commit f204a48)

Changes to startReplacementDaemon (src/commands/daemon-replace.ts):

  1. Fall back only when the manager is positively idle. A new managerState gate (readServiceManagerState in src/commands/daemon.ts) decides.
    • systemd: readSystemdManagerState/classifySystemdManagerState (src/daemon/systemd.ts) run systemctl --user show --all -p LoadState,ActiveState,SubState,Job through the runner. The manager is idle only when ActiveState is inactive/failed, SubState is not auto-restart and Job is empty, or when the unit is not loaded.
    • launchd: readLaunchdManagerState (src/daemon/launchd.ts). Superseded by round 12: idle only on launchd's confirmed not-found answer.
    • Pending or unknown returns the service error with blocked: "service-pending" and a message that the service may still start hackd.
  2. A rejected service start is caught and treated like a failed one behind the same gate. If the fallback is not used or fails, the original error is rethrown.
  3. An explicit start outcome decides, not the trace. startStoppedDaemon returns { kind: "service-refused" | "service-attempted" | "direct-attempted", exitCode, manager }. The new onStartRequested hooks fire just before systemctl start and before the launchd reload/kickstart. An early systemd refusal with an empty trace therefore still falls back, subject to the gate.

Tests:

  • A service still activating, with no pid or socket, gets no direct start: pending and unknown both give service-pending.
  • A rejected service start falls back when idle, and rethrows when pending or when the direct start fails.
  • An early refusal with an empty trace falls back.
  • An untrusted unit refuses without the start hook firing and leaves the trace at none.
  • The systemd state classifier and runner, and the launchd state for absent, failed, running, retrying, conditional and unreadable cases, are covered.
  • Not verified on a real systemd: that --all prints an empty Job= when no job is queued. If it doesn't, the gate reads unknown and refuses (safe direction).
  • On macOS a loaded Hack plist (conditional KeepAlive) is never idle, so the fallback does not run there while the job stays loaded.

Review round 10 fixes (commit 9892b28)

Owner decision: make the guarantee structural.

  1. Retained drop-ins are checked before the stop. When a rewrite is planned, refuseSystemdStartBeforeReplacing (src/commands/daemon-systemd.ts) also runs the effective-configuration checks the start applies after the write, on the configuration that survives it. These are checkSystemdLoadedUnitPaths and the new checkSystemdEffectiveService/checkSystemdEffectivePath (src/daemon/systemd.ts); checkBeforeEnable now uses the same function. A program retarget checks only the effective PATH, because the loaded copy still names the missing program. Test: a stale managed unit with a trusted drop-in that puts a world-writable directory on PATH is refused before the stop as rewrite-refused, with no systemctl mutation and the unit unchanged.
  2. A replacement never leaves hackd stopped. startReplacementDaemon (src/commands/daemon-replace.ts, used by startDaemon in src/commands/daemon.ts on Linux and macOS):
    • After stopping a running daemon, it starts the replacement through the service. If that is refused or fails for any reason, it starts this home's hackd directly (startDaemonDetached).
    • It reports action restarted, fallback: "direct" and the service's blocked reason, and warns to run hack daemon install.
    • It skips the fallback when the service path was not used, or when a hackd of this home already answers (the service may still be starting one). If the direct start also fails, the service's error stands.
    • It applies in the post-update reconcile-only mode too, since that daemon was running.
    • --json data gains the closed fallback field.
    • Tests: a forced post-stop service refusal leads to a direct start and fallback: "direct" in the data. There is no fallback when the service start worked, is still starting, or was already direct, and a failing direct start keeps the error.
    • Limit: the fallback is tested through startReplacementDaemon with injected starts, not through a CLI run with a real incompatible daemon.

Review round 9 fix (commit be501d4)

  • The pre-stop check and the start share one non-mutating prepare step. In src/commands/daemon-systemd.ts, planStoppedStart (the plan) and computeUnitRewrite (the new unit text, or why it cannot be written) are now used both by refuseSystemdStartBeforeReplacing, before a direct daemon is stopped, and by the start itself (prepareSystemdUnitForStart and rewriteUnitForPlan), so the two cannot drift.
    • Covered: replacement executable resolution and durability, renderability, and install's program and PATH checks. The missing-program retarget now runs those program and PATH checks too.
    • A refused rewrite is refused before the stop with the new closed reason blocked: "rewrite-refused".
    • The durability warning of a content rewrite moved from the compute step to the apply step, so the check stays silent.
    • Runtime failures and changes between the check and the start remain outside the guarantee; this is documented in a code comment and in docs/architecture.md.
  • Tests: a missing program with a non-durable replacement makes the check refuse as rewrite-refused, with no systemctl mutation and a byte-identical unit. The start then refuses for the same reason, and a durable replacement passes. A stale managed unit whose rendered service PATH includes a directory others can write is refused before the stop.

Codex finding: decide before stopping a direct daemon (commit 0cc5af5)

  • macOS: checkRunningLaunchdDaemon (src/commands/daemon-launchd.ts) now also checks the loaded launchd job before the caller stops this home's incompatible direct daemon (launchdJobBlocksReplacement). If the job's plist binds another home it refuses as home-binding; if the job is not proven this home's it refuses as unproven-owner. Exit 1, E_DAEMON_SERVICE, and the running daemon is left alone.
  • Linux had the same stop-then-refuse ordering. refuseSystemdStartBeforeReplacing (src/commands/daemon-systemd.ts) runs in handleRunningDaemonAtStart (src/commands/daemon.ts) before the stop. It refuses up front when startViaSystemdIfInstalled would refuse afterwards: an untrusted unit, a unit whose Hack home cannot be read, a missing program in read-only mode, or loaded files or a program Hack cannot vouch for. A unit serving another home is fine: start then starts this home's hackd directly.
  • Tests:
    • launchd: an incompatible direct daemon with a loaded job bound to another HACK_HOME, and one whose job is unproven, are refused without any repair or kickstart; this home's job lets the replacement go on.
    • systemd: an unreadable unit home and a read-only missing program are refused with no systemctl start, stop, restart, reload, enable or reset-failed calls; another home's unit and this home's usable unit let the replacement go on.
    • Limit: the stop itself is skipped because these checks return before stopProvenDaemon in handleRunningDaemonAtStart. An end-to-end run with a real incompatible daemon was not reproduced, because faking a daemon of another version that passes the identity proof isn't practical here.

Codex finding: continued unit lines (commit d5c7d88)

  • A home binding on a continued line no longer slips through. systemd joins a line ending in a backslash with the next, so Environment=FOO=1 \ followed by HACK_HOME=... escaped the per-line check. I chose the simpler, safe option over re-implementing systemd's line joining: findDroppedHomeBinding (src/daemon/systemd.ts) now treats any continued line in the main unit file as a home binding. The unit Hack renders has none. The automatic rewrite is refused with blocked: "home-binding", and hack daemon install replaces the unit. Tests: a managed, stale unit carrying HACK_HOME on a continued line is not rewritten when stopped, and is kept when running and compatible; the unit file is byte-identical, with no reload or restart. Unit tests cover continuations of unrelated settings and trailing spaces.

Review round 6 fix (commit e6d7355)

  • A failed launchd status query no longer counts as "not loaded".
    • getLaunchdServiceStatus (src/daemon/launchd.ts) now records how launchctl print answered, as observation: "reported" | "not-found" | "failed". Only exit 113 with launchd's "Could not find service" message is not-found (isLaunchdServiceNotFound); every other failure is failed.
    • loaded: false keeps its meaning for status and doctor display.
    • proveLaunchdJobOwnership accepts an unloaded job only when observation is not-found. A failed or missing observation is unproven: repair returns unproven-owner with no bootout, rewrite or bootstrap.
    • Tests through the macOS test driver: print fails with an unrecognized error while bootout would succeed, and only print is called, with the plist unchanged; launchd's own exit-113 answer still allows the refresh. Unit tests cover the classifier and the ownership proof.

Codex bot findings, second batch (commit 3f5ebed)

  • P2: reconcile-only no longer leaks into a replacement daemon. detachedDaemonEnvironment (src/daemon/spawn.ts) drops HACK_DAEMON_RECONCILE_ONLY next to HACK_DAEMON_SERVICE_READ_ONLY for every detached hackd: the reconcile's replacement and the client autostart. A --foreground start also ignores the flag (src/commands/daemon.ts). The guard still applies to the post-update daemon start --json itself. Tests: the spawn environment drops both flags. A CLI test runs daemon start --foreground with the flag set, as a leaked replacement would; the daemon runs, and a reconcile-only start --json then reports none with the daemon running.
  • P1: launchd job ownership before an automatic refresh or retarget. repairLaunchdProgramIfInvalid (src/daemon/launchd.ts) now refuses in two cases:
    • home-binding: the plist's EnvironmentVariables sets HACK_HOME, or a HOME other than the one Hack writes (plistKeepsThisHome, read with the new parseLaunchdPlistEnvironment);
    • unproven-owner: the loaded job is running and its pid is not proven this home's hackd (proveLaunchdJobOwnership, the proveOwnedDaemonProcess proof that stop uses). A running job without a pid also counts as unproven.
      startViaLaunchdIfLoaded reports these and does not kickstart such a job. Tests through the macOS test driver: a plist with another HACK_HOME, a plist with another HOME, and a running job whose pid is not this home's hackd. Each is refused with no bootout/bootstrap and an unchanged plist. Unit tests cover the home check and the job proof. The launchctl stub's job state is now configurable, so refresh tests model a loaded job that runs nothing.

Review round 4 fixes (commit 720f51e)

  1. The final restart proof fails closed. proveOwnershipBeforeRestart (src/commands/daemon-systemd.ts) does a fresh state read just before the restart. It requires an available user manager and no state error. Otherwise it refuses with blocked: "unproven-owner" (refuseUnobservedRestart) and never reuses the earlier snapshot. Test: the final systemctl show fails while the home queries succeed; no restart.
  2. Read-only pending restarts no longer repeat. In planDaemonReconcile (src/daemon/reconcile.ts), a pending restart no longer restarts a daemon that already runs this version under HACK_DAEMON_SERVICE_READ_ONLY. It stays pending and is reported (blocked: "read-only", with a warning to run hack daemon install). A version mismatch still restarts. Test: two consecutive read-only starts with a pending reload; the first restarts the mismatched daemon, the second does not, and the marker stays.
  3. Escaped and spaced plist keys. In src/daemon/launchd-plist.ts, root keys are compared exactly as written, without trimming. A root key containing an XML entity or character reference (Progr&#97;m) is unknown. Program values are not trimmed either. Tests: an escaped Program key pointing at another existing executable is never replaced (macOS test driver, unknown-program, no launchctl calls); a Program key with spaces is not treated as Program; an escaped ProgramArguments key; a value with a leading space.

Review round 3 fixes (commit 76681cb)

  1. Only root-dict keys count. parseLaunchdPlistProgram moved to src/daemon/launchd-plist.ts and is now a small structured reader. It drops comments, tracks dict and array nesting, and reads Program/ProgramArguments only from the root <dict>. CDATA, unterminated comments, malformed or unbalanced tags, unknown value types and repeated root keys are unknown. Tests: a nested Program in EnvironmentVariables, a commented-out Program, and both combined with a foreign top-level program. The driver tests check that these stay other-program with no launchctl call and an unchanged plist, plus unit cases for each unknown structure.
  2. Stat errors fail closed. classifyLaunchdProgramPath (src/daemon/launchd.ts) reports missing only for ENOENT/ENOTDIR; every other error is unknown, which readLaunchdProgramState turns into unknown-program. pathExists is unchanged. Tests: injected EACCES, EIO, ENOENT, ENOTDIR and a code-less error, plus a real EACCES in the darwin driver (a program inside a mode-000 directory stays unknown-program; skipped when running as root).

Codex bot findings (commit 8f8ad89)

  • P1: prove ownership before an automatic restart. refuseUnprovenRestart (src/commands/daemon-systemd.ts) runs the stop path's checkMainProcessServesHome + decideUnitStop before any change and again just before the restart. Cgroup membership of this home's pid is no longer enough. Without the proof, start exits 1 with blocked: "unproven-owner" and changes nothing. Tests: this home's hackd started by a job inside another home's service, and a reload that pointed HACK_HOME at this home while the main process serves another. In both cases there is no restart, no reload and no unit change.
  • P2: finish a pending reload before the retried restart. When the restart plan has no rewrite but NeedDaemonReload=yes, completeReloadBeforeRestart runs daemon-reload and rechecks the unit's home before restarting. A failed reload returns 1 without restarting and keeps the restart-pending marker. Under read-only the marker is kept after the restart. Test: a reload failure on the repair, a rerun while it still fails (no restart, marker kept), then a rerun that reloads before it restarts and clears the marker.

Review round 2 fixes (commit f1b08f7)

  1. Failed launchd refreshes no longer report success. startViaLaunchdIfLoaded (src/commands/daemon-launchd.ts) handles "failed" before the onlyIfStale shortcut: it logs an error and returns 1. Tests cover an unload failure and a load failure after a successful unload (driver with a failing launchctl stub), and the in-process start path (no kickstart, exit 1).
  2. launchd program semantics. parseLaunchdPlistProgram (src/daemon/launchd.ts) gives Program precedence, and the new unknown state is refused (unknown-program). readLaunchdProgramState treats only an established absence as missing. Tests cover Program alone pointing at another existing hack (left alone), Program conflicting with ProgramArguments (Program wins), an unreadable program (never replaced), and parser cases.
  3. macOS restart refuses another program. refuseForeignLaunchdProgram runs in restartDaemon (src/commands/daemon.ts) before the stop. The stopped-service start path also refuses instead of kickstarting another or unreadable program. A darwin CLI test runs daemon restart --json against another existing executable: exit 1, E_DAEMON_SERVICE, detail.blocked: "other-program", only launchctl print called, plist unchanged.
  4. Identity checks fail closed. readInvokerInSystemdUnit (src/daemon/systemd.ts) returns unknown for a missing, empty or non-path ControlGroup and for an empty or malformed /proc/self/cgroup. runsInsideJob (daemon-launchd.ts) treats a loaded job with no usable pid as inside. Tests cover each case. The fakes now report a ControlGroup for a running unit, as systemd does.

Follow-up: earlier Hack plists count as managed (commit fb18902)

Owner decision: isHackManagedPlist (src/daemon/launchd.ts) is true for the marker or Hack's Label. An unmarked legacy plist that runs this CLI's executable is refreshed and gains the marker. An unmarked plist with another label is still unmanaged, and another existing program is still other-program. The systemd header rule is unchanged.

Review round 1 fixes (commit 91f8e18)

  1. The service never restarts its own caller. Planner guard: src/daemon/reconcile.ts (planDaemonReconcile). Linux check: readInvokerInSystemdUnit in src/daemon/systemd.ts, used by reconcileRunningSystemdDaemon in src/commands/daemon-systemd.ts. macOS check: checkRunningLaunchdDaemon in src/commands/daemon-launchd.ts, built on hasAncestorProcess in src/daemon/process-ancestry.ts.
  2. The service's Hack home is kept. findDroppedHomeBinding in src/daemon/systemd.ts and readContentBlock in daemon-systemd.ts block the rewrite. restartReconciledUnit reads the unit's home again after the rewrite and before the restart.
  3. launchd keeps another existing program. readLaunchdProgramState, isSameExecutableFile and the rewritten repairLaunchdProgramIfInvalid in src/daemon/launchd.ts. The mismatch refusal is in checkRunningLaunchdDaemon.
  4. The update hook needs proof that hackd is running. observePostUpdateDaemon in src/commands/daemon-upgrade.ts requires an API answer on this home's socket. The child runs with HACK_DAEMON_RECONCILE_ONLY=1, handled in startDaemon in src/commands/daemon.ts.
  5. Observation is best effort. It now sits inside the try in reconcileDaemonAfterUpdate.
  6. Owner policy on hand-written units. SYSTEMD_MANAGED_HEADER and isHackManagedUnit (src/daemon/systemd.ts), LAUNCHD_MANAGED_MARKER and isHackManagedPlist (src/daemon/launchd.ts). Docs and the diagram are updated.

Docs

  • docs/architecture.md, section "After an upgrade: a stale service is repaired by start": the rules, the managed-header policy, the inside-service and home-binding refusals, launchd, the update hook, the diagram and the JSON table (with blocked).
  • v5 start-here docs/versions/v5/upgrade.md, guides/upgrade-from-v4.md, reference/command-contracts.md and docs/cli.md are updated.
  • docs/reference/cli.md is regenerated.

Validation

All runs used Bun 1.4.4 and umask 022, with tests hermetic: temp HOME, fake systemctl/loginctl/launchctl, darwin-preload driver.

Latest, head 417f741 (merge of next at 081a526):

  • Focused tests (29 files): 530 pass, 35 skipped (macOS-only), 0 fail.
  • machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe + upgrade-installer + host-command-observation, plus next's candidate-launcher, native-candidate-launcher, upgrade-check, upgrade-apply, lifecycle-supervisor-observers and lifecycle-supervisor-controller: 264 pass, 16 skipped, 0 fail.
  • Both typechecks, bun run check (exit 0; 59 warnings, one more from next's code; the only warnings in this PR's files are the two complexity warnings that already exist on next), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 2f26965 (repair only from explicit install):

  • Focused tests (29 files): 530 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe + upgrade-installer + host-command-observation + doctor-command: 175 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no new warnings), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • Source changes are only in src/daemon/systemd.ts and src/commands/daemon-systemd.ts, so the native acceptance scripts were not rerun. They last passed at 2088b96.

At 6b3a292 (stop not blocked by the reload and start checks):

  • Focused tests (29 files): 529 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe + upgrade-installer + host-command-observation: 143 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no new warnings), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • The only source change is in src/daemon/systemd.ts, already in the previous round's set, so the native acceptance scripts were not rerun. They last passed at 2088b96, below.

At 2088b96 (no automatic repair, relationship directories):

  • Focused tests (29 files): 527 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe + upgrade-installer + host-command-observation: 143 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no new warnings), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 7f489a6 (ps and lsof in the process proofs):

  • Focused tests (29 files: the previous 28 plus tests/system-tool.test.ts): 525 pass, 35 skipped (macOS-only), 0 fail.
  • Guards, process and host-command observation suites (machine-output-contract, machine-output-success-shapes, sync-spawn-bound, bounded-spawn-sync, update-observe, upgrade-installer, host-command-observation, host-exec-lifetime, host-exec-tty, usage-by-service-cli, usage-details, shared-host-lifecycle, native-authored-host-processes, measure-lsof-scan): 222 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no new warnings), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 3e6461c (launchctl and ps by absolute path):

  • Focused tests (28 files): 522 pass, 35 skipped (macOS-only), 0 fail.
  • Guards and related suites (machine-output-contract, machine-output-success-shapes, sync-spawn-bound, bounded-spawn-sync, update-observe, upgrade-installer, native-domain-dns-launchd, projects-observe, local-network-hint-call-sites): 179 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no new warnings), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At c0f95fc (replacement spawn and post-update re-exec):

  • Focused tests (28 files): 521 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe + upgrade-installer: 138 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; the only warnings in changed files are two complexity warnings that already exist on next), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.
  • At 81c4372 (first fix only), before the second fix: the focused set (518 pass), contract and guard tests (121 pass), typechecks, check, docs, generators and build passed. The acceptance scripts ran once, on the final tree above.

At 91cc203 (review round 32):

  • Focused tests (28 files): 515 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe: 121 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.
  • 3a49b04 only renames one test; tests/launchd-isolation.test.ts was rerun after it (69 pass).
  • 3a49b04 also picked up .pr-body.md by mistake: a local copy of this PR description used to edit it, holding only the public PR text. 91cc203 removes it. It is not in the branch's final diff, and bun run privacy:check passes.

At 13a4809 (review round 31):

  • Focused tests (28 files): 510 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe: 121 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 6792a61 (review round 30):

  • Focused tests (28 files: the previous 27 plus tests/launchd-print.test.ts): 507 pass, 35 skipped (macOS-only), 0 fail.
  • machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe: 121 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 240dd08 (merge of next at 34b5053, on top of fix 8610528):

  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 501 pass, 35 skipped (macOS-only), 0 fail.
  • machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync + update-observe: 121 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff after the merge commit.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.
  • At 8610528, before the merge, the same focused set (501 pass), contract and guard tests (107 pass), typechecks, check, docs, generators and build passed. The acceptance scripts ran once, on the merged tree above.

At 7b3fe8a (launchd PATH check):

  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 498 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync: 107 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At 080a785 (review round 27):

  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 494 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync: 107 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check, and bun run build pass. Generators: no diff.
  • The only non-test source change is in src/commands/daemon-systemd.ts, so the native acceptance scripts were not rerun. They last passed at 26d9fbd, below.

At 26d9fbd (kept-program checks):

  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 492 pass, 35 skipped (macOS-only), 0 fail. machine-output-contract + machine-output-success-shapes + sync-spawn-bound + bounded-spawn-sync: 107 pass, 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check pass. Generators (generate-machine-output-reference.ts, generate:agent-plugins, docs:cli-reference): no diff.
  • bun run build, bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At af7957d (merge of next at 345dc0d with the #835 envelope rule):

  • tests/machine-output-contract.test.ts + tests/machine-output-success-shapes.test.ts + tests/sync-spawn-bound.test.ts + tests/cli-spawn-env-isolation.test.ts: 110 pass, 0 fail. This includes the usage-error probe for each daemon command and the generated inventory table.
  • Focused tests (27 files): 489 pass, 35 skipped (macOS-only), 0 fail.
  • Both typechecks, bun run check (exit 0; no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check pass.
  • Generators (scripts/generate-machine-output-reference.ts, bun run generate:agent-plugins, bun run docs:cli-reference) rerun after the commit: no diff.
  • bun run build, bun run build:config-compiler (throwaway zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

At effa1dc (review round 24 on top of the security fix):

  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 489 pass, 35 skipped (macOS-only), 0 fail. tests/sync-spawn-bound.test.ts + tests/bounded-spawn-sync.test.ts: 6 pass.
  • Both typechecks, bun run check (exit 0, no warnings in changed files), bun run privacy:check, bun run docs:prepare plus bun run docs:check pass. bun run docs:cli-reference: no diff.
  • The only source change is in src/commands/daemon-systemd.ts, so the native acceptance scripts were not rerun. They last passed at 01b83fd, below.

At 01b83fd (security fix on top of the bf74aaf merge):

  • tests/sync-spawn-bound.test.ts + tests/bounded-spawn-sync.test.ts: 6 pass, 0 fail.
  • Focused tests (27 files, including tests/cli-spawn-env-isolation.test.ts): 488 pass, 35 skipped (macOS-only), 0 fail.
  • Both typechecks, bun run check (exit 0; its warnings are all in files this PR does not change; bunx ultracite check on the changed files is clean), bun run privacy:check, bun run docs:prepare plus bun run docs:check pass. bun run docs:cli-reference and bun run generate:agent-plugins: no diff.
  • bun run build, bun run build:config-compiler (zig cc wrapper, deleted afterwards), bun run check:native-plan-cli (config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks) and scripts/check-config-compiler-cli.ts on its own pass.

Latest, after merging next at bf74aaf (merge commit 172559e; head c15779c):

  • Merge: no conflicts. The generated plugins/hack/skills/hack-diagnose/references/error-codes.md merged cleanly, and bun run generate:agent-plugins reproduces it unchanged, with E_DAEMON_SERVICE. (The earlier merge of e47681e in 1dd298b conflicted only on that file's content revision line and was resolved by regenerating it.)
  • Bounded spawns (test: bound synchronous spawns in tests and name the command on timeout (HACK-2247) #857): compileHack in tests/daemon-systemd-cli.test.ts now uses spawnSyncBounded with a 60 s bound, below the test's 90 s timeout. This PR adds no other synchronous spawn. Its two long-lived async children (a foreground daemon and a sleep holder) stay on Bun.spawn; their tests kill them and they do not block the event loop.
  • tests/sync-spawn-bound.test.ts + tests/bounded-spawn-sync.test.ts: 6 pass, 0 fail. tests/cli-spawn-env-isolation.test.ts passes (it is in the focused set).
  • Focused tests: 484 pass, 35 skipped (macOS-only), 0 fail across 27 files.
  • Both typechecks, bun run check (exit 0; its warnings are all in files this PR does not change, and bunx ultracite check on every changed TS file is clean), bun run privacy:check, and bun run docs:prepare plus bun run docs:check pass. bun run docs:cli-reference: no diff.
  • bun run build and bun run build:config-compiler pass. bun run check:native-plan-cli passes all 8 scripts it runs: config-compiler CLI, env plan, host 139, routing 141, endpoint 153, process 145, acquisition 198, config-import 49 checks. scripts/check-config-compiler-cli.ts run on its own also passes. This Linux host has no system cc, so the compiler was linked with a throwaway zig cc wrapper (CC and CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER), deleted afterwards.

Earlier rounds:

  • bun run --cwd packages/cli typecheck, bun run typecheck, bun run check (pre-existing warnings only), bunx ultracite check on changed tests and new modules, bun run privacy:check, and bun run docs:prepare && bun run docs:check all pass.
  • Focused tests: 482 pass, 35 skipped (macOS-only), 0 fail across 27 files, including the spawn env isolation guard (after the CI env-isolation fix). Round 1 regression coverage:
    • tests/daemon-reconcile.test.ts: inside-service and content-block planning, header and home-binding detection, cgroup containment, process ancestry, launchd inside-job keep and refuse, plist marker, executable identity (symlink, hard link, different file), a stale pid file is not a running daemon, a rejected observer, and a daemon that stopped before the child ran.
    • tests/daemon-systemd.test.ts:
      • a mismatched daemon under this unit is refused from inside the cgroup, with no restart and no reload;
      • a stale but compatible service is kept from inside;
      • the restart goes ahead from another cgroup;
      • a hand-written stale unit is reported, not rewritten;
      • a custom-home unit is not rewritten, and a mismatch restarts it unchanged;
      • the home is re-checked after the rewrite, with no restart when it changed.
    • tests/launchd-isolation.test.ts (darwin driver): another existing program is never retargeted, an unmarked legacy plist with Hack's label and this CLI's program is refreshed and gains the marker, an unmarked plist with another label is not refreshed, and a marked plist with an older environment is refreshed.
    • tests/daemon-systemd-cli.test.ts: reconcile-only start --json with nothing running reports none and writes no pid file. The earlier end-to-end managed-unit rewrite and restart still passes.
  • bun run build + ./dist/hack smoke under a temp HOME (HACK_SYSTEMD_DISABLE=1 HACK_LAUNCHD_DISABLE=1 HACK_DAEMON_AUTOSTART_DISABLE=1):
Run Result
reconcile-only start --json, nothing running none, no pid file
start --json started
reconcile-only start --json, running none
stop --json stopped

The temp daemon was confirmed gone afterwards.

Not verified

  • A real systemd user manager or real launchd.
  • A real cgroup layout under a running hackd.service: the cgroup comparison is unit-tested with fixed strings.
  • macOS ancestry through real ps, and a darwin compatible-running refresh end to end. The repair decisions run through the darwin driver.
  • A real hack update across two releases.
  • Known limit, documented: an explicit hack daemon restart run from inside the service still stops the service. Only the automatic reconcile is guarded.
  • After a load that fails following a successful unload, the plist on disk is already current but not loaded; later start runs take the direct-start path, as before this PR.
  • Behaviour change: a plist pointing at an older versioned install path that still exists (for example an old Homebrew Cellar path) is no longer normalized automatically. It is now refused with hack daemon install, as item 3 requires.

Tracks HACK-2216.

🤖 Generated with Claude Code

`hack daemon start` and `restart` now bring an installed login service up to
the running CLI (HACK-2216). A systemd unit that runs this CLI's executable
(same path or a link to the same file) but differs from what it renders is
rewritten with the install checks and restarted; a running hackd of another
version under that unit is restarted through systemctl. A unit for another
existing hack, an unreadable ExecStart, another Hack home, an isolated
HACK_HOME and HACK_DAEMON_SERVICE_READ_ONLY keep their refusals. A launchd
plist whose program is current but whose content differs is rewritten too.

The pure decision lives in planDaemonReconcile (src/daemon/reconcile.ts).
`hack update` runs the new executable's `daemon start --json` after a
successful install when a service is installed and hackd is running; a
failure is reported with `hack daemon install` and never fails the update.

`hack daemon start|stop|restart|install --json` print one envelope with
data schema hack.daemon-<command>/v1 (manager, action, stale reasons, daemon
version/pid); failures use the new E_DAEMON_SERVICE code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hack-docs Ready Ready Preview Nov 15, 2026 3:42pm UTC

Request Review

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T15:45:14.207370Z 2da1ec1 Manual request
🔒 Security Review ✅ Completed 2026-11-15T15:52:51.616076Z 2da1ec1 Manual request

Security findings

Advisory findings (8)

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49c0550bbb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/daemon-systemd.ts Resolved
Comment thread src/commands/daemon-systemd.ts Resolved
Comment thread src/commands/daemon.ts Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 49c0550bbb

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

roodboi and others added 5 commits November 14, 2026 19:56
Review round 1 on HACK-2216:

- Never rewrite or restart the service from inside it. Linux compares
  /proc/self/cgroup with the unit's ControlGroup (through the systemctl
  runner); macOS checks whether the launchd job pid is an ancestor. A
  stale but compatible daemon is kept (exit 0, blocked inside-service), a
  mismatched one refused (exit 1); an unreadable check counts as inside.
- Keep the service's Hack home: a managed unit whose main file sets
  HACK_HOME, HOME or an EnvironmentFile that the renderer would drop is not
  rewritten, and after a rewrite the unit's home is read again before the
  restart.
- launchd: a plist that runs another existing hack (compared by realpath
  and dev/ino) is never retargeted; only a missing or virtual program is.
  A mismatched daemon under it is refused with hack daemon install.
- hack update: require hackd to answer on this home's socket before
  reconciling, run the child in reconcile-only mode
  (HACK_DAEMON_RECONCILE_ONLY=1, which never starts a stopped daemon), and
  keep observation inside the best-effort boundary.
- Owner policy: rewrite on content drift only when the unit starts with
  Hack's managed header; launchd plists get an equivalent managed marker.
  Hand-written units keep the HACK-2052 behaviour (report, no rewrite).

--json data gains a closed `blocked` field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision on HACK-2216: a launchd plist counts as Hack's when it carries the managed marker or its Label is Hack's (it is only read at Hack's own LaunchAgents path). Plists written by earlier Hack versions, which have no marker, are therefore refreshed on upgrade when they run this CLI's executable, and gain the marker. A plist that runs another existing program stays other-program and is refused. launchd has no drop-ins, so the systemd header rule is unchanged and not applied here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iew round 2)

- A launchd refresh that fails (unload or load) is an error with E_DAEMON_SERVICE, never 'already running'.
- Read the plist's executable as launchd does (Program over ProgramArguments[0]); an unreadable program is 'unknown-program' and never replaced automatically. Absence is established only by no program, a missing path or a virtual path.
- macOS restart refuses a service whose program is another existing or unreadable executable before stopping anything; a stopped service under such a plist is not kickstarted (earlier Hack retargeted it instead).
- A missing or empty ControlGroup, an empty or malformed /proc/self/cgroup, and a loaded launchd job without a usable pid count as inside the service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tomatic restart

- The automatic reconcile restart now needs the same main-process proof as stop (checkMainProcessServesHome + decideUnitStop), before any change and again just before the restart. Without it, start refuses with blocked unproven-owner and changes nothing.
- When an earlier repair wrote the unit but daemon-reload failed (NeedDaemonReload=yes with the restart-pending marker), start finishes the reload and rechecks the unit's home before restarting. A failed reload stops there and keeps the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on stat errors

Review round 3 on HACK-2216:
- Parse the plist structurally (src/daemon/launchd-plist.ts): comments are dropped, only keys of the root dict count, and CDATA, unbalanced or unknown structure is unknown. A nested or commented-out Program key no longer makes a foreign top-level program look missing.
- A plist program is missing only when stat reports ENOENT or ENOTDIR (classifyLaunchdProgramPath); EACCES, EIO and other errors are unknown and never authorize a retarget. pathExists is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76681cbb68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/daemon.ts Resolved
Comment thread src/commands/daemon-launchd.ts Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 76681cbb68

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

roodboi and others added 2 commits November 14, 2026 20:36
… restarts and escaped plist keys

Review round 4 on HACK-2216:
- The final ownership proof before an automatic restart needs a fresh, available state read with no state error; a failed read refuses (blocked unproven-owner) and the earlier snapshot is never reused.
- Under HACK_DAEMON_SERVICE_READ_ONLY a pending restart alone no longer restarts a daemon that already runs this version; it stays pending and is reported (blocked read-only). A version mismatch is still restarted.
- launchd plist root keys are read exactly as written (no trimming), and a root key with an XML entity or character reference is unknown; program values are not trimmed either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… launchd job ownership

Codex findings on HACK-2216:
- spawnDetachedDaemon now drops HACK_DAEMON_RECONCILE_ONLY (next to HACK_DAEMON_SERVICE_READ_ONLY) via detachedDaemonEnvironment, and a --foreground start ignores the flag, so a daemon a post-update reconcile replaces actually runs.
- Before any automatic launchd refresh or retarget, the plist must not bind another home (HACK_HOME, or a HOME other than the one Hack writes): blocked home-binding. A running job's pid must be proven this home's hackd (proveOwnedDaemonProcess); a running job without one is unproven: blocked unproven-owner. Start refuses to kickstart such a job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f5ebed22e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/daemon/systemd.ts Resolved
…oaded

Review round 6 on HACK-2216: getLaunchdServiceStatus now records how launchctl print answered (observation: reported, not-found or failed). Only exit 113 with launchd's 'Could not find service' message is not-found. proveLaunchdJobOwnership accepts an unloaded job only when its absence is positively identified, so a failed query refuses the rewrite (blocked unproven-owner) with no bootout, rewrite or bootstrap. loaded keeps its meaning for status and doctor display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 3f5ebed22e

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6d735501d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/daemon-launchd.ts Resolved
… a continued line

Codex finding on HACK-2216: systemd joins a line ending in a backslash with the next, so a HACK_HOME carried on a continued line escaped the per-line home-binding check and the rewrite dropped it. findDroppedHomeBinding now treats any continued line in the main unit file as a home binding (the simpler, safe option: the rendered unit has none), so the rewrite is refused with blocked home-binding and the unit is left byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: d5c7d885ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: d5c7d885ee

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

roodboi and others added 4 commits November 14, 2026 21:07
…cement can start

Codex finding on HACK-2216: start stopped this home's incompatible direct daemon and only then found the service start refused, leaving no daemon running.
- macOS: checkRunningLaunchdDaemon now also refuses up front when the loaded launchd job binds another home (home-binding) or is not proven this home's (unproven-owner).
- Linux had the same stop-then-refuse ordering: refuseSystemdStartBeforeReplacing checks what startViaSystemdIfInstalled would refuse (untrusted unit, unreadable unit home, read-only missing program, unvouched loaded files or program) before the stop.
Both refuse with exit 1 and leave the running daemon alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…check and the start

Review round 9 on HACK-2216: the check before replacing a direct daemon now runs the same planning (planStoppedStart) and non-mutating rewrite computation (computeUnitRewrite) as the start after the stop. A missing program with no durable replacement, an unrenderable program, or a program or service PATH that fails install's checks is refused before the stop (blocked rewrite-refused). The retarget of a missing program now also runs install's program and PATH checks. Runtime failures and concurrent changes between the check and the start remain outside the guarantee (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck to a direct start

Review round 10 on HACK-2216 (owner decision: make the guarantee structural):
- The pre-stop check now also applies the effective-configuration checks the start runs after a rewrite (loaded files, effective PATH and service programs, via checkSystemdEffectiveService/checkSystemdEffectivePath) to the configuration that survives it, so an unsafe PATH from a retained drop-in is refused before the stop (rewrite-refused).
- When a running daemon was replaced and the service then cannot start the replacement for any reason, start starts this home's hackd directly (startReplacementDaemon), reporting action restarted, fallback direct, the service's blocked reason and a hack daemon install warning. If the direct start fails too, the service error stands. --json data gains a closed fallback field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… explicit start outcome

Review round 11 on HACK-2216:
- The fallback runs only on positive evidence that the manager will not start hackd: systemd inactive or failed, not auto-restart, no queued job (or the unit not loaded); launchd job not loaded, or loaded, stopped and asking for no retry. Pending or unknown returns the service error with blocked service-pending.
- A rejected service start is treated like a failed one behind the same gate; if the fallback is not used or fails, the original error is rethrown.
- The service start reports an explicit outcome (service-refused, service-attempted or direct-attempted, from onStartRequested hooks at the systemd start and the launchd reload/kickstart), and the fallback decides from it instead of the --json trace, so an early systemd refusal with an empty trace still falls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3a49b04 picked up .pr-body.md, a local copy of the PR description used to edit it. It holds only the public PR text; it does not belong in the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91cc203cd4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/daemon.ts Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 91cc203cd4

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/commands/daemon-upgrade.ts Resolved
roodboi and others added 2 commits November 15, 2026 10:26
…t's version

When a newer CLI ran by absolute path while an older hack came first on PATH, a replacement's direct fallback spawned the PATH-first hack, and the start counted as successful on pid and socket alone, so the post-update reconcile reported an upgrade that did not happen.

- A replacement's direct start (startReplacementDaemon's fallback, and the direct start after a replaced daemon in startStoppedDaemon) runs executingDaemonArgv: the executing compiled binary, or the same runtime and entry point for a source invocation, never the PATH-first hack. Ordinary starts with nothing to replace are unchanged.
- startReplacementDaemon now requires verifyStarted: after any replacement start, through the service or directly, the daemon must answer on this home's socket and report this CLI's version (probeDaemonApi compatibility). Otherwise exit 1 with action none (E_DAEMON_SERVICE), never restarted, so the update hook records daemon.status failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h and the published file

The post-update hook re-executed binaryPath after publishRelease. With an install directory another account can write (a custom HACK_INSTALL_BIN), a payload could be renamed over it before the spawn and run as the updating user.

- publishRelease returns installedBinary: the device and inode of the file it published (the rename keeps the staged inode) and the sha256 it already records in the backup manifest. update.ts passes it to reconcileDaemonAfterUpdate.
- verifyInstalledBinary runs right before the spawn: checkServiceExecutableTrusted on the path (no directory on the way replaceable by another account), no link at the final component (lstat regular file), and the same device, inode and sha256. Any failure runs nothing and records daemon.status failed with a hack daemon start next step; the update itself still succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: c0f95fcb37

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: c0f95fcb37

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/commands/update.ts Resolved
roodboi and others added 2 commits November 15, 2026 10:56
runLaunchctl spawned bare launchctl through the inherited PATH, so a planted launchctl in an earlier PATH directory ran as the user after hack update, which this PR makes reach launchd automatically.

- runLaunchctl, the only place the launchd module runs launchctl, now runs /bin/launchctl (LAUNCHCTL_PATH, launchctlArgv), so every launchd operation is covered. The macOS ancestry check already ran /bin/ps; it is now named (PS_PATH, psParentArgv) and pinned by a test.
- Test seam: useLaunchctlBinaryForTests, called only by the test preload tests/helpers/launchctl-stub-preload.ts from HACK_TEST_STUB_LAUNCHCTL. No production code reads an environment variable for the binary.
- Other spawns in files this PR touches: systemctl, loginctl and getent go through the systemd runner, which resolves them on PATH but refuses one whose path another account could replace (unchanged); the replacement spawns (executingDaemonArgv) and the post-update re-exec run absolute, verified paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…proofs

An automatic replacement reached bare ps (the orphan sweep's process table, and the ownership proof's process births) and a PATH-resolved lsof (socket owners), so a planted ps or lsof in an earlier PATH directory ran as the user.

- resolveSystemTool (src/lib/system-tool.ts) returns a fixed absolute path, never PATH: /bin/ps and /usr/sbin/lsof on macOS; on Linux the first existing of /bin/ps or /usr/bin/ps, and /usr/bin/lsof or /usr/sbin/lsof, accepted only if checkReplaceableChain passes (the chain check checkServiceExecutableTrusted runs). None qualifying is null.
- process.ts (listProcessTable, listOpenUnixSockets, the socket-owner probe) and host-command-observation.ts (readObservedProcesses) use it. No trusted ps makes the orphan scan unknown, and the sweep then stops nothing and starts nothing; no trusted lsof leaves socket owners unknown; no trusted ps leaves a process snapshot unknown.
- Test seam useSystemToolForTests, test-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 7f489a6f93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 7f489a6f93

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/commands/daemon-systemd.ts Resolved
roodboi and others added 3 commits November 15, 2026 11:33
…ackd relationship directories

On the automatic paths checkSystemdUnitFile chmod-ed a writable user-unit directory before the load-path check, and no check looked at hackd.service.wants/.requires/.upholds. Another account that could write the directory could pre-plant payload.service plus a .wants link; the directory then looked trusted, daemon-reload loaded both and the restart ran the payload.

- checkOwnerControlledPath repairs an owned directory only with repair: true. Only explicit hack daemon install passes it (securing its own directories and the checks right after); start, stop, restart, the pre-stop check and the automatic gate refuse a group- or other-writable directory with untrusted-unit and change nothing. On next, these checks repaired on every path.
- checkUnitLoadDirectory now checks hackd.service.wants/, .requires/ and .upholds/ in every user-unit load directory: each must be absent, or an empty directory owned by this user or root that others cannot write. A link, any entry or a writable one is refused. Hack's renderer and install create none of them. This runs before every reload, start and restart (and in stop and install).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision: a stop loads and runs no new unit code, and blocking it would keep the user from shutting hackd down exactly when something looks wrong.

checkSystemdStopHooks no longer runs checkSystemdUnitTrusted plus checkSystemdUnitLoadPaths, which now refuse writable unit and load directories and hackd.service.wants/.requires/.upholds. Instead it checks the unit file itself (a regular file this user owns that others cannot write; directories neither judged nor repaired), then, as before, what systemd has loaded (fragment and loaded drop-ins) and the absence of ExecStop hooks. Stop's ownership and home checks are unchanged, and stop never reloads. Start, restart (which runs the full checks before this one), the pre-stop check, the post-update reconcile, the automatic gate and install keep the full refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nstall

Automatic rewrites (rewriteUnitForPlan into installSystemdUnit) still passed repair: true inside the shared installer, so a directory that became writable after the earlier gate was chmod-ed closed and the run went on with whatever was planted in that window.

- installSystemdUnit takes repair, defaulting to false, and threads it into ensureOwnerWritableOnlyDirectories, the drop-in check and the unit-file check. Without it, a writable unit directory or a replaceable unit file is refused (untrusted), with the mode unchanged and nothing written, reloaded or restarted.
- Only explicit hack daemon install (installDaemonSystemdService) passes repair: true. It is reached from the install command and from doctor --fix after an explicit confirmation; no automatic path calls it.
- rewriteUnitForPlan records blocked untrusted-unit when the installer refuses for trust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 2f26965f5d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2f26965f5d

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Ready for lead: 2f26965f5d5fe71ea586f2362321d8b6bdca7e13. Normal merge of next at 34b50534ed. Your Mac run at 240dd08d13 predates the launchd and systemd security fixes below; please rerun the launchd cases on this head.

What changed since 240dd08d13 (each from a GitHub Codex security finding or my reviewer, each with tests that fail when the fix is disabled):

  • launchd, before any automatic kickstart, bootstrap or reload:
    • Plist trust and byte binding: the plist and ~/Library/LaunchAgents chain must be trusted. The bootstrap is bound to the exact checked bytes, compared again on the same dev/ino.
    • Plist contents: the plist must set an explicit PATH and exactly <program> daemon start --foreground.
    • Program and PATH trust: the program and every PATH directory must pass the service trust rules.
    • Loaded job: launchd's loaded job must match, read from /bin/launchctl print. A stopped job is reloaded from the checked plist, and an unparseable re-read after our own reload is accepted.
    • Install: every plist install checks the program before it bootstraps.
  • systemd:
    • No automatic path repairs a writable unit or drop-in directory; it refuses with untrusted-unit. Only explicit hack daemon install (or hack doctor --fix after confirmation) repairs.
    • A foreign hackd.service.wants, .requires or .upholds entry blocks every reload, start and restart.
    • The last check before restart validates the loaded ExecStart path and the effective PATH.
    • hack daemon stop is exempt: it loads no new unit code and never reloads.
  • Executables: /bin/launchctl. ps and lsof come only from fixed, trust-checked system paths, failing closed. Replacement and direct-fallback spawns run the executing CLI (never the PATH-first hack) and must report this CLI's version.
  • Post-update hook: it runs the new binary only from a trusted path, after re-checking the dev/ino and sha256 that publishRelease recorded. Any failure records failed, and the update still succeeds.

What I ran

  • Exact-head run 43710916520: all jobs green, macOS included: test shards 1–8, docker-e2e 1–4, Candidate core and Config compiler on ubuntu, ubuntu-arm and macOS, linux-process-lifetime, Runtime state models and runtime-images. Nothing was queued or skipped.
  • Local (Bun 1.4.4, umask 022): 29-file focused set, 530 pass, 35 skip (macOS-only), 0 fail. Contract, guard, update, installer, host-command and doctor suites: 175 pass. Typechecks, check, privacy, docs, generators (no diff) and build pass. The nine acceptance scripts last ran at 2088b9698; the source changes since are only in the systemd files.
  • Persistent Codex reviewer, same thread across 38 rounds: VERDICT CLEAN at 2f26965f5d.
  • GitHub Codex code review: no major issues at 2f26965f5d. Security review: no findings. computer pr review-status: CLEAR at 2f26965f5d; every bot thread has a reply and is resolved.

Decisions to confirm

  1. Only Hack-managed units or plists are rewritten automatically: systemd's managed header; a plist with the marker or Hack's label, running this CLI or a missing program.
  2. A running daemon is stopped or restarted only with ownership proven, the home matching, and the executing compiled CLI being the service's program. A source checkout can repair a stopped service but never restarts a running one.
  3. The direct-start fallback runs only when the manager positively reports idle. On macOS that means the job is not loaded, so launchd's own retry is relied on.
  4. With an isolated HACK_HOME or HACK_LAUNCHD_DISABLE, launchd counts as idle without a query.
  5. An explicit hack daemon restart run from inside hackd's own service still stops it (a known limit).
  6. Intel Homebrew: /usr/local/bin is often group-admin writable, so hack daemon install and automatic refresh now refuse there. This is strict by design, and the reviewer agreed.
  7. systemctl, loginctl and getent stay PATH-resolved through the systemd runner's replaceable-chain check.

Mac pass: how to run it without touching the owner's login services

launchd cannot be isolated on the owner's macOS account. The label is the fixed dance.hack.hackd in gui/<uid>. A private HOME only moves the plist, and there is no label override. So:

  • launchd cases: use a separate macOS user (its own gui/<uid>) or a disposable macOS VM, with that user's default HOME and ~/.hack. Install the new binary at a stable path such as ~/.local/bin/hack, and keep the previous release as "old". Capture --json output and /bin/launchctl print gui/$(id -u)/dance.hack.hackd at each step.
    1. Not-found answer: with the job unloaded, launchctl print exits 113 with "Could not find service" (assumed by the code; fails closed if different).
    2. Real launchctl print parsing: the loaded job's program, arguments and environment PATH parse. If not, a stopped job still starts through the reload fallback, but a running job with a stale plist refuses (rewrite-refused). Please capture one real print output for us.
    3. Legacy plist refresh: old daemon install, then new daemon start --json gives refreshed or reinstalled. The plist gains the marker and HACK_DAEMON_LOGIN_SERVICE, and the daemon reports the new version.
    4. Post-update: an older hack first on PATH, then the new binary by absolute path. It restarts into the new binary only when the plist's program is that file; otherwise other-program, with nothing stopped. Then a real hack update between two releases reconciles through the new binary and never starts a stopped daemon.
    5. Refusals with no launchctl change: an unsafe PATH directory or program (rewrite-refused); a plist that sets another HACK_HOME (home-binding); custom arguments; a group-writable plist or ~/Library/LaunchAgents (untrusted-unit).
    6. down during restart: hackd is never left stopped when the service could start it.
    7. KeepAlive: after a refused kickstart, the result is service-pending and launchd restarts hackd itself.
    8. Inside-service detection: hack daemon start from a process under the job reports inside (real /bin/ps ancestry), and nothing restarts.
    9. Source checkout: bun index.ts daemon start against a running service gives unproven-owner, with nothing stopped.
    10. Intel Homebrew (if available): the refusal message for a group-writable /usr/local/bin is clear.
    11. feat(autostart): start opted-in Compose projects once per boot #803 interaction: a refresh adds HACK_DAEMON_LOGIN_SERVICE to an older plist.
  • Safe on the owner's account (no launchd): a private HOME, HACK_HOME=$HOME/.hack, HACK_LAUNCHD_DISABLE=1 and HACK_DAEMON_AUTOSTART_DISABLE=1. daemon start|restart|stop|install --json give {ok, data:{schema:"hack.daemon-<cmd>.v1", …}} (install refuses with E_PERMISSION). Replacing an older direct daemon spawns the executing CLI and verifies its version, and HACK_DAEMON_RECONCILE_ONLY=1 never starts a stopped daemon.

Not verified anywhere: real launchd, real /bin/ps and /usr/sbin/lsof, a real systemd user manager, and a real hack update between releases. Every test uses fakes or the darwin driver on Linux.

I will not push to this branch after this note.

…econcile

Brings in next at 081a526 (#811 supervisor opt-in runner, #863 candidate launcher names HACK-2251, #808 desktop Logs tab). No conflicts. The CLI reference, machine-output table and error-code reference regenerate unchanged; E_DAEMON_SERVICE kept.

#863 changes which launcher printed commands name and the candidate update refusal; it does not change the executing-CLI identity, the service program checks, durability or the post-update binding, and a candidate install is still refused before any install or reconcile. #811's supervisor reads process identities through process-tree.ts and runs its own sibling binary; it does not use this PR's ps/lsof helpers, process-ancestry or the orphan sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 417f7418bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/daemon-systemd.ts Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 417f7418bf

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

roodboi and others added 2 commits November 15, 2026 15:40
…icit model gap

AGENTS.md asks release-critical lifecycle changes to keep docs/reference/v5-model-coverage.md current. Owner decision: docs only, no new TLA+ model. The new row names the invariants the reconcile relies on, the source transitions and regression tests that cover them, and what stays unqualified: no finite model or bound, one reconcile at a time per home, crashes between stop and start, power loss, real systemd and launchd, real launchctl print parsing, concurrent manager restarts, and the pending Mac launchd pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coverage row said "Coverage gap: none" and then that no model covers it. It now reads "Coverage gap: no maintained model; not model-checked."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 2da1ec10a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 2da1ec10a2

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/daemon/launchd.ts Resolved

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant