Fix #384: install the daemon against a durable global bin from every enrollment path#385
Conversation
…enrollment path ensureDurableBinForNpx had a single call site (the walkthrough picker finale), so `hyp daemon install` and the enrolling `remote login`/`join` lane installed launchd/systemd against the ephemeral `~/.npm/_npx/<hash>` bin. When npx exits that bin is gone, leaving the host recorded but with no `hyp` control surface (status/policy/detach/uninstall all impossible). Move the isNpxBinPath -> ensureDurableBinForNpx upgrade into installDaemon, the single choke point every non-dry-run install funnels through, so the walkthrough, `hyp daemon install`, and join/enroll all inherit the invariant "a daemon is never installed against an _npx bin." The upgrade result rides back on the install plan (globalInstall) so the walkthrough still reports it. Escape hatches preserved: an explicit --bin sets binExplicit and is kept verbatim; dry-run renders through planDaemonInstall and never reaches the upgrade. The now-redundant walkthrough one-off is removed so the upgrade runs exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
neutral review — PR #385 (head cc9e842)Verdict: CLEAN. No actionable findings. The invariant "a daemon is never installed against an Choke-point coverage (invariant verified)
No path writes launchd/systemd bypassing Escape hatch #1 — explicit
|
Root cause
ensureDurableBinForNpx(the only code that turns an_npxinvocation into a durable global bin) had exactly one call site: the walkthrough picker finale (src/core/cli/walkthrough.js), gated behind!skipInstall && !finale.binPath. Every other install path resolvedbinPathfromprocess.argv[1](the_npxbin) and skipped the upgrade:hyp daemon install(runDaemonInstallinsrc/core/commands/daemon.js)remote login/hyp joinlane, which finishes by callingrunDaemonInstall([], ctx)viaenrollCentralSinkSo enrolling through any non-walkthrough path pinned launchd/systemd to the ephemeral
~/.npm/_npx/<hash>/...bin. When npx exits there is nohypon PATH: the host is recorded but has no control surface (hyp status/hyp policy set/hyp detach/hyp daemon uninstallall impossible).Fix (shared choke point)
Move the
isNpxBinPath(...)->ensureDurableBinForNpx(...)upgrade intoinstallDaemon()(src/core/daemon/install.js) via aresolveDurableBinPathhelper, run inside the existingdaemon.installspan before the platform unit is written. Every non-dry-run install funnels throughinstallDaemon, so the walkthrough,hyp daemon install, and the join/enroll lane all inherit the invariant: a daemon is never installed against an_npxbin.plan.globalInstall) so the walkthrough still reportssummary.globalInstalland span attributes.Escape hatches preserved
--bin: callers setbinExplicit: true(walkthrough usesfinale.binPath !== undefined,runDaemonInstallusesparsed.binPath !== undefined);installDaemonkeeps that bin verbatim.planDaemonInstall/renderDaemonInstall, which never reachinstallDaemon, so the upgrade never runs.Regression test
test/core/daemon-install-durable-bin.test.jsdrivesinstallDaemon(platformdarwin, fake launchctl, injected npm runner seam so no real global install happens) with an_npx-style binPath and asserts the written plist references a durable global bin, not the_npxpath. It also asserts explicit--binand dry-run bypass. Confirmed failing onorigin/master(3 of 5 subtests fail) and passing with the fix.Test result
Full
npm test: 2629 pass, 7 fail. The 7 failures are the pre-existingtest/core/leave-command.test.jsfailures present on plainmaster, unrelated to this change.Fixes #384