Skip to content

Add an install sandbox with mock launchctl, security, and systemctl - #825

Open
philcunliffe wants to merge 3 commits into
masterfrom
tooling/install-sandbox
Open

Add an install sandbox with mock launchctl, security, and systemctl#825
philcunliffe wants to merge 3 commits into
masterfrom
tooling/install-sandbox

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

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 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 without mocks 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 your whole login session.

What

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.

The interception surface is one function: every service-manager and keychain call already funnels through runServiceCommand(bin, args) in src/core/daemon/service_ops.js, which spawns bare binary names and so resolves them through PATH.

The mocks keep state on disk, so round trips behave like the real thing:

Call Mock behaviour
bootstrap of a loaded label exit 5, Bootstrap failed: 5: Input/output error
bootout of an unknown label exit 3, No such process
print of an unknown service exit 113
verify-cert on an untrusted CA exit 1 - which is what makes attach pick base-URL mode
delete-certificate of an absent cert exit 1, could not be found

Two flags cover paths that are otherwise painful to stage:

  • --spawn makes bootstrap really 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-trust behaves like the user cancelling the macOS password dialog.

lib/fake_central.js speaks the four endpoints @hypaware/central calls (identity/bootstrap, identity/refresh, config, ingest/<signal>), so joinleave → 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
  • CA trust round trip: untrusted → trusted → untrusted, and --refuse-trust leaving nothing behind
  • npx hypaware@latest - 62MB of download landed in the sandbox cache, not the user's
  • hyp join → org-driven attach → hyp leave → upgrade → rejoin, ending with a green proxy trust block
  • the real daemon on the host was untouched throughout

test/core/sandbox-shim.test.js pins the exit-code contract the kernel branches on (7 tests, all passing). npm run typecheck is clean.

npm test shows 8 pre-existing failures on this machine, none related to this change: 7 are tests that read the real ~/.hyp (they pass under an isolated HYP_HOME) and one is the known node 24.2.0 rmSync dangling-symlink bug in leave-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

philcunliffe and others added 3 commits August 17, 2026 21:02
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>
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