Add an install sandbox with mock launchctl, security, and systemctl - #825
Open
philcunliffe wants to merge 3 commits into
Open
Add an install sandbox with mock launchctl, security, and systemctl#825philcunliffe wants to merge 3 commits into
philcunliffe wants to merge 3 commits into
Conversation
Testing an install method - npx, npm install -g, hyp init, hyp join or leave, attach, daemon install - meant doing it on your own machine. Pointing HOME at a temp dir is not enough: launchd service labels, launchctl setenv, and the login keychain all live in a per-uid namespace, so a sandboxed `hyp daemon install` boots out your real daemon and a sandboxed `hyp daemon uninstall` deletes your real CA trust and unsets NODE_USE_SYSTEM_CA for the whole login session. scripts/sandbox/hyp-sandbox redirects HOME, HYP_HOME, and the npm global prefix into a throwaway root, and puts mock launchctl / security / systemctl first on PATH. Every one of those calls already funnels through runServiceCommand() in src/core/daemon/service_ops.js, which spawns bare binary names, so PATH is the whole interception surface. The mocks keep state on disk, so round trips behave like the real thing: bootstrap twice is launchd error 5, bootout of an unknown label is 3, print of an unknown service is 113, and an untrusted CA fails verify-cert - which is what makes attach pick base-URL mode. Two flags cover the hard-to-stage paths: --spawn makes bootstrap really start the plist's program (with a KeepAlive supervisor, because HypAware applies a pulled config by exiting and expecting launchd to restart it), and --refuse-trust behaves like the user cancelling the macOS password dialog. lib/fake_central.js speaks the four endpoints @hypaware/central calls, so join, leave, and rejoin are testable without a real fleet. It serves its fleet config from a file you can edit mid-run. The npm global prefix is set through the sandbox HOME's .npmrc rather than npm_config_prefix, because npm 11 refuses to read a prefix set in the environment and ensureDurableBinForNpx shells out to `npm config get prefix`. What the sandbox still cannot prove is real keychain trust and real client pickup; README.md says so and points at a second macOS user account for those. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hyp remote login` is the enrollment path that needs no bootstrap token, so the sandbox could not test it: the fake server spoke only the four endpoints the central plugin calls, not the two the login lane uses. It now answers `GET /v1/identity/login/start` with a 302 to the client's loopback receiver, and `POST /v1/identity/token` for both the authorization_code and refresh_token grants, returning the gateway_* triple that makes a sign-in enroll the machine. The "browser" is anything that fetches the start URL, so curl completes a sign-in. Which answers a question that came up while supporting a coworker: login recovers a machine that ran `hyp leave` (verified: enrolls, installs the daemon, attaches in proxy mode) but not one that only ran `hyp detach` - the enrollment work sits behind `if (seeded.length === 0)`, so an already-enrolled machine re-seeds its identity and stops, leaving a freshly upgraded binary unrun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mock `security add-trusted-cert` succeeded unconditionally, so a fleet setup with no human in it looked like it established CA trust. That is not something the sandbox knows: trusting a CA in the login keychain is gated by the macOS password dialog, attach runs the same code in the CLI and in the daemon's reconciler, and nobody is watching a background LaunchAgent. The mock turned an open question into a confident wrong answer, which is worse than not modelling it at all. The supervisor now marks the subtree launchd starts with HYP_SANDBOX_SERVICE, so the shim can tell a daemon-issued call from one the user typed, and a daemon-issued trust is answered with the error macOS gives when it cannot prompt: "User interaction is not allowed." An unattended join now ends with `login keychain: not trusted - ... run hyp attach claude to retry` while the launchd env, which needs no dialog, lands by itself. That matches what people report from real machines. It is an assumption, not a measurement - only a real keychain (a second macOS user account, or a VM) can settle whether a LaunchAgent may raise that dialog. So it is the pessimistic default, it is named in the call log's note, it is printed by `hyp-sandbox info`, and `--trust-from-daemon grant` exercises the other branch. README.md states it as an assumption and says why a always-succeeds mock is the wrong shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Testing an install method -
npx,npm install -g,hyp init,hyp join/leave, attach, daemon install - meant doing it on your own machine and cleaning up afterwards.Pointing
HOMEat a temp dir is not enough. launchd service labels,launchctl setenv, and the login keychain all live in a per-uid namespace, so without mocks a sandboxedhyp daemon installboots out your real daemon, and a sandboxedhyp daemon uninstalldeletes your real CA trust and unsetsNODE_USE_SYSTEM_CAfor your whole login session.What
scripts/sandbox/hyp-sandboxredirectsHOME,HYP_HOME, and the npm global prefix into a throwaway root, and puts mocklaunchctl/security/systemctlfirst onPATH.The interception surface is one function: every service-manager and keychain call already funnels through
runServiceCommand(bin, args)insrc/core/daemon/service_ops.js, which spawns bare binary names and so resolves them throughPATH.The mocks keep state on disk, so round trips behave like the real thing:
bootstrapof a loaded labelBootstrap failed: 5: Input/output errorbootoutof an unknown labelNo such processprintof an unknown serviceverify-certon an untrusted CAdelete-certificateof an absent certcould not be foundTwo flags cover paths that are otherwise painful to stage:
--spawnmakesbootstrapreally start the plist's program, under a KeepAlive supervisor - HypAware applies a pulled config by exiting and expecting launchd to restart it, so a one-shot spawn would leave the machine daemon-less exactly when a fleet config lands.--refuse-trustbehaves like the user cancelling the macOS password dialog.lib/fake_central.jsspeaks the four endpoints@hypaware/centralcalls (identity/bootstrap,identity/refresh,config,ingest/<signal>), sojoin→leave→ rejoin is testable without a real fleet. Its fleet config is a file on disk you can edit mid-run; the ETag is the content hash, so the daemon picks up edits on its next poll.Verified
Driven end to end on macOS against real 1.22.0 and 1.23.0 installs from npm:
daemon install/status/restart/uninstall, with a real daemon process on a spare port under--spawn--refuse-trustleaving nothing behindnpx hypaware@latest- 62MB of download landed in the sandbox cache, not the user'shyp join→ org-driven attach →hyp leave→ upgrade → rejoin, ending with a greenproxy trustblocktest/core/sandbox-shim.test.jspins the exit-code contract the kernel branches on (7 tests, all passing).npm run typecheckis clean.npm testshows 8 pre-existing failures on this machine, none related to this change: 7 are tests that read the real~/.hyp(they pass under an isolatedHYP_HOME) and one is the known node 24.2.0rmSyncdangling-symlink bug inleave-command.test.js.Limits, stated in the README
The mock keychain records trust; it does not make TLS interception work, and real clients read your real
~/.claude. For those, a second macOS user account is still the honest test.🤖 Generated with Claude Code