diff --git a/AGENTS.md b/AGENTS.md
index d87b9819..d84fc8ec 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,6 +74,14 @@ actually shipped in V1.
- Do not treat the hermetic smokes as the only release gate. They run with a
temp `HYP_HOME` and `HYP_DEV_TELEMETRY=1`, which is useful for deterministic
regression checks but does not prove installed-daemon behavior.
+- Use `scripts/sandbox/hyp-sandbox` to exercise install methods (`npx`,
+ `npm install -g`, `hyp init`, `hyp join`/`leave`, attach, daemon install)
+ without touching your own install. It redirects `HOME`, `HYP_HOME`, and the
+ npm global prefix into a throwaway root, and puts mock `launchctl` /
+ `security` / `systemctl` first on `PATH` so those calls never reach your real
+ launchd domain or login keychain (both are per-uid, so a temp `HOME` alone
+ does not sandbox them). See
+ [`scripts/sandbox/README.md`](scripts/sandbox/README.md).
## Smoke Test Model
diff --git a/scripts/sandbox/README.md b/scripts/sandbox/README.md
new file mode 100644
index 00000000..c8c42d3c
--- /dev/null
+++ b/scripts/sandbox/README.md
@@ -0,0 +1,246 @@
+# hyp-sandbox
+
+Test HypAware installation methods without touching your real install.
+
+`hyp-sandbox` runs any command with `HOME`, `HYP_HOME`, and the npm global
+prefix pointed at a throwaway directory, and with **mock `launchctl`,
+`security`, and `systemctl`** first on `PATH`. So a real `hyp daemon install`,
+`hyp attach claude`, or `hyp daemon uninstall` runs its real code all the way
+down, but the LaunchAgent, the CA trust, and `NODE_USE_SYSTEM_CA` land in the
+sandbox instead of your login session.
+
+```sh
+scripts/sandbox/hyp-sandbox info # what the sandbox looks like
+scripts/sandbox/hyp-sandbox hyp daemon install # workspace build, sandboxed
+scripts/sandbox/hyp-sandbox run npx hypaware@latest status
+scripts/sandbox/hyp-sandbox calls # what it intercepted
+scripts/sandbox/hyp-sandbox reset # throw it all away
+```
+
+Default root is `~/.hyp-sandbox`; override with `--root
` or
+`HYP_SANDBOX_ROOT`.
+
+## Why the mocks exist
+
+`HOME` alone is not enough. launchd service labels and `launchctl setenv` live
+in a **per-uid** namespace, and the login keychain that macOS actually consults
+is the one belonging to your user, not to `$HOME`. Without the mocks, a
+sandboxed `hyp daemon install` would boot out **your real daemon**, and a
+sandboxed `hyp daemon uninstall` would delete **your real CA trust** and unset
+`NODE_USE_SYSTEM_CA` for your whole login session.
+
+Every one of those calls goes through a single seam,
+`runServiceCommand(bin, args)` in `src/core/daemon/service_ops.js`, which
+spawns the bare binary name and so resolves it through `PATH`. That is what
+the shims intercept.
+
+## What is isolated
+
+| Thing | Where it goes in the sandbox |
+|---|---|
+| Cache, config, logs (`~/.hyp`) | `/home/.hyp` |
+| Client attach files (`~/.claude`, `~/.codex`) | `/home/...` |
+| LaunchAgent plists | `/home/Library/LaunchAgents` |
+| launchd bootstrap / bootout / kickstart / setenv | `/state/launchd.json` |
+| Keychain CA trust | `/state/keychain.json` |
+| systemd user units | `/state/systemd.json` |
+| `npm install -g`, `npx` downloads | `/npm-global`, `/npm-cache` |
+| Every intercepted call | `/state/calls.jsonl` |
+
+## What is **not** isolated
+
+- **Network.** Real requests go to real upstreams.
+- **Ports.** The gateway defaults to `127.0.0.1:18521`, the same port your live
+ daemon uses. Use `hyp-sandbox seed-config 18621` or `hyp-sandbox port 18621`
+ before starting a sandbox daemon.
+- **Real client behaviour.** Claude Code and Codex on your machine read your
+ real `~/.claude` / `~/.codex`, so the sandbox proves what HypAware *writes*,
+ not that a real client picks it up.
+- **Actual trust.** The mock keychain records trust; it does not make TLS
+ interception work. To prove real trust you still need a real keychain, i.e. a
+ second macOS user account or a VM.
+
+## What the sandbox *assumes* (and cannot prove)
+
+One assumption is load-bearing enough to state on its own, because getting it
+wrong produced a confidently wrong answer once already.
+
+**A daemon-issued `security add-trusted-cert` is refused.** Attach runs the same
+code in the CLI and in the daemon's reconciler
+(`ensureDarwinProxyTrust`, `hypaware-core/plugins-workspace/claude/src/index.js`),
+and trusting a CA in the login keychain is gated by the macOS password dialog.
+Nobody is watching a background LaunchAgent, so the sandbox answers a
+daemon-issued trust with the error macOS gives when it cannot prompt:
+
+```
+SecTrustSettingsSetTrustSettings: User interaction is not allowed.
+```
+
+The result is that an unattended fleet setup ends with:
+
+```
+proxy trust:
+ login keychain: not trusted - Remote Control inbound will not work, run `hyp attach claude` to retry
+ launchd env: NODE_USE_SYSTEM_CA=1 set
+```
+
+which matches what people report from real machines: the settings and the
+launchd env land by themselves, the keychain trust waits for a human.
+
+**This is an assumption, not a measurement.** Whether real macOS lets a
+LaunchAgent raise that dialog can only be settled on a real keychain - a second
+macOS user account or a VM. The sandbox takes the pessimistic reading so a test
+run cannot claim an unattended setup established trust when it may not have.
+Flip it with `--trust-from-daemon grant` to exercise the other branch.
+
+A mock that always succeeds is worse than no mock: it turns an open question
+into a false answer. If you add mocks here, prefer failing the uncertain case
+and naming the assumption in the `note` the call log records.
+
+Related, and *not* modellable here: Remote Control also needs
+`NODE_USE_SYSTEM_CA=1` in the environment when Claude Code boots.
+`launchctl setenv` only reaches processes launched afterwards, so the terminal
+app must be fully quit (Cmd-Q) and reopened. Nothing inside a sandbox can
+reproduce your terminal's inherited environment - check `echo
+$NODE_USE_SYSTEM_CA` in the real one.
+
+## Commands
+
+| Command | What it does |
+|---|---|
+| `info` | Print the sandbox env, and warn if the shims are not first on `PATH` |
+| `shell` | Open a bare `bash` inside the sandbox (`exit` to leave) |
+| `run ` | Run one command inside the sandbox |
+| `hyp ` | Run the workspace build's CLI inside the sandbox |
+| `central start\|stop\|status\|log\|config` | Run a stand-in central server so `hyp join` / `leave` / rejoin can be tested without a real fleet |
+| `seed-config [port]` | Write a minimal v2 config with a non-clashing gateway port |
+| `port ` | Rewrite every `listen` port in the sandbox config |
+| `calls [n]` | Show the last n intercepted `launchctl`/`security` calls |
+| `state` | Dump the mock launchd, keychain, and systemd state |
+| `reset` | Delete the sandbox root (asks first) |
+
+| Flag | Effect |
+|---|---|
+| `--root ` | Sandbox root (default `~/.hyp-sandbox`) |
+| `--spawn` | Mock `launchctl bootstrap` really starts the plist's program, so you get a live sandboxed daemon with a real pid, status file, and bound port |
+| `--refuse-trust` | `security add-trusted-cert` behaves like the user cancelling the macOS password dialog, for testing the degraded attach path |
+| `--trust-from-daemon ` | Whether a *daemon-issued* trust succeeds. Default `refuse` - see "What the sandbox assumes" |
+| `--verbose` | Echo every intercepted call to stderr as it happens |
+
+## Worked examples
+
+Install method: published package, global install.
+
+```sh
+scripts/sandbox/hyp-sandbox run npm install -g hypaware@latest
+scripts/sandbox/hyp-sandbox run hyp status
+```
+
+Install method: local tarball.
+
+```sh
+npm pack
+scripts/sandbox/hyp-sandbox run npm install -g ./hypaware-1.23.0.tgz
+scripts/sandbox/hyp-sandbox run hyp init
+```
+
+Full daemon lifecycle with a live process:
+
+```sh
+scripts/sandbox/hyp-sandbox seed-config 18621
+scripts/sandbox/hyp-sandbox --spawn hyp daemon install
+scripts/sandbox/hyp-sandbox hyp daemon status # real pid, real bound port
+scripts/sandbox/hyp-sandbox hyp daemon uninstall # bootout stops the process
+scripts/sandbox/hyp-sandbox calls
+```
+
+Attach and detach, including the cancelled-dialog path. Proxy-mode attach only
+offers itself on an interactive terminal, so run these from `hyp-sandbox
+shell`; a one-shot `hyp-sandbox hyp attach claude` falls back to base-URL mode:
+
+```sh
+scripts/sandbox/hyp-sandbox --refuse-trust hyp attach claude
+scripts/sandbox/hyp-sandbox state # keychain still empty
+scripts/sandbox/hyp-sandbox hyp attach claude
+scripts/sandbox/hyp-sandbox state # cert recorded as trusted
+```
+
+## Worked example: 1.22 → `hyp leave` → 1.23 → rejoin with the proxy
+
+The fake central server (`hyp-sandbox central`) serves a fleet config from
+`/state/fleet-config.json`, so the whole enrollment lifecycle is
+testable. Edit that file mid-run to change what the fleet says; the daemon
+picks it up on its next poll (the ETag is the file's content hash).
+
+```sh
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade central start
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run npm install -g hypaware@1.22.0
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp join http://127.0.0.1:18700 any-token
+# ...org-driven attach lands in base-URL mode (1.22 has no proxy support at all)
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp leave
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run npm install -g hypaware@1.23.0
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp join http://127.0.0.1:18700 any-token
+scripts/sandbox/hyp-sandbox --root ~/.hyp-sandbox-upgrade run hyp status # proxy trust: all green
+```
+
+Two things the mocks had to grow for this to work, both worth knowing:
+
+- **KeepAlive.** HypAware applies a pulled config by *exiting* and letting
+ launchd restart it. The mock `launchctl` therefore runs a supervisor per
+ bootstrapped service (throttled at 1s rather than launchd's 10s), or the
+ machine would be daemon-less exactly when the fleet config lands.
+- **`identity`.** A served central sink block must include an `identity` key
+ (`{}` is enough once the machine has an `identity.json`); without it the sink
+ fails to materialize, which takes the config-pull loop down with it. The
+ machine then recovers only when probation expires and rolls the config back.
+
+### Testing `hyp remote login`
+
+The fake server also speaks the attended sign-in flow (`/v1/identity/login/start`
+and `/v1/identity/token`), so an enrolling login is testable without a real
+identity provider. The "browser" is anything that fetches the start URL - the
+server answers with a 302 straight to the client's loopback receiver, so `curl`
+completes a sign-in:
+
+```sh
+hyp-sandbox --root ~/.hyp-sandbox-upgrade --spawn run hyp remote login sandbox --no-browser > login.log 2>&1 &
+sleep 4
+curl -sL "$(grep -o 'http://127.0.0.1:18700/v1/identity/login/start[^ ]*' login.log | head -1)"
+```
+
+What that surfaced: **login recovers a machine that ran `hyp leave`, and does
+not recover one that only ran `hyp detach`.** The enrollment work (including the
+daemon install that makes a freshly upgraded binary actually run) sits behind
+`if (seeded.length === 0)` in `remoteLogin` - an already-enrolled machine
+re-seeds its identity and stops. `hyp daemon install` is the idempotent step
+that covers both.
+
+The `security add-trusted-cert` in this flow is issued by the **daemon**, not
+by a command the user ran. The sandbox mock accepts it silently; a real Mac
+raises its password dialog. That step is the one thing this sandbox cannot
+prove - verify it in a second macOS user account before telling anyone the
+flow is unattended.
+
+## Known snag: `npm config get prefix`
+
+npm 11 refuses to *read* `prefix` whenever it is set explicitly, in an
+`.npmrc` or in the environment:
+
+```
+npm error The prefix option is protected, and can not be retrieved in this way
+```
+
+The sandbox has to set it, so `ensureDurableBinForNpx`
+(`src/core/cli/global_install.js`), which shells out to `npm config get
+prefix`, fails inside the sandbox on the `npx hypaware` → `hyp init` path.
+
+This is not only a sandbox artifact: **any** user with `prefix=` in their
+`~/.npmrc` (the usual way to avoid `sudo` for global installs) hits the same
+error on that path. `npm prefix -g` returns the same value and is not
+protected.
+
+## Adding a mock
+
+The mocks live in `lib/shim.js`, one function per tool, each returning
+`{ code, out, err, note }`. Add a subcommand branch there; the wrapper scripts
+in `/bin` are regenerated on every run, so nothing else needs touching.
diff --git a/scripts/sandbox/hyp-sandbox b/scripts/sandbox/hyp-sandbox
new file mode 100755
index 00000000..f280597c
--- /dev/null
+++ b/scripts/sandbox/hyp-sandbox
@@ -0,0 +1,364 @@
+#!/usr/bin/env bash
+#
+# hyp-sandbox - run HypAware installs against a throwaway HOME.
+#
+# Redirects HOME, HYP_HOME, and the npm global prefix into a sandbox root,
+# and puts mock `launchctl` / `security` / `systemctl` first on PATH so a
+# real `hyp daemon install` or `hyp attach claude` never reaches the login
+# session's launchd domain or the login keychain. See README.md.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SHIM="$SCRIPT_DIR/lib/shim.js"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+ROOT="${HYP_SANDBOX_ROOT:-$HOME/.hyp-sandbox}"
+SPAWN=0
+TRUST_REFUSE=0
+TRUST_FROM_DAEMON=refuse
+VERBOSE=0
+
+usage() {
+ cat <<'EOF'
+usage: hyp-sandbox [options] [args...]
+
+commands:
+ shell open a subshell inside the sandbox
+ run [args...] run one command inside the sandbox
+ hyp [args...] run the workspace build's CLI inside the sandbox
+ info print the sandbox env and what is installed in it
+ calls [n] show the last n intercepted launchctl/security calls
+ state dump the mock launchd + keychain state
+ central run the stand-in central server (start|stop|status|log|config)
+ seed-config [port] write a minimal config (gateway + claude + codex)
+ port rewrite the sandbox config's gateway listen port
+ reset delete the sandbox root (asks first)
+ path print the sandbox root
+
+options:
+ --root sandbox root (default: $HYP_SANDBOX_ROOT or ~/.hyp-sandbox)
+ --spawn mock launchctl really starts the plist's program
+ --refuse-trust `security add-trusted-cert` acts like a cancelled dialog
+ --trust-from-daemon
+ whether a daemon-issued `add-trusted-cert` succeeds.
+ Default refuse: the login keychain is gated by a
+ password dialog and the daemon is a background agent
+ with nobody watching. This is an ASSUMPTION the
+ sandbox cannot verify - see README.md.
+ --verbose echo every intercepted call to stderr
+
+examples:
+ hyp-sandbox run npm install -g /path/to/hypaware-1.23.0.tgz
+ hyp-sandbox run hyp init
+ hyp-sandbox --spawn run hyp daemon install
+ hyp-sandbox calls 20
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --root) ROOT="$2"; shift 2 ;;
+ --root=*) ROOT="${1#*=}"; shift ;;
+ --spawn) SPAWN=1; shift ;;
+ --refuse-trust) TRUST_REFUSE=1; shift ;;
+ --trust-from-daemon) TRUST_FROM_DAEMON="$2"; shift 2 ;;
+ --trust-from-daemon=*) TRUST_FROM_DAEMON="${1#*=}"; shift ;;
+ --verbose) VERBOSE=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) break ;;
+ esac
+done
+
+if [[ $# -eq 0 ]]; then usage; exit 64; fi
+
+CMD="$1"; shift
+
+# Absolute, so a relative --root cannot follow the sandboxed HOME around.
+mkdir -p "$ROOT"
+ROOT="$(cd "$ROOT" && pwd)"
+
+SANDBOX_HOME="$ROOT/home"
+SANDBOX_HYP_HOME="$SANDBOX_HOME/.hyp"
+SANDBOX_BIN="$ROOT/bin"
+NPM_PREFIX="$ROOT/npm-global"
+
+provision() {
+ mkdir -p \
+ "$SANDBOX_HYP_HOME" \
+ "$SANDBOX_HOME/Library/LaunchAgents" \
+ "$SANDBOX_HOME/Library/Keychains" \
+ "$SANDBOX_HOME/.config/systemd/user" \
+ "$SANDBOX_BIN" \
+ "$NPM_PREFIX/bin" \
+ "$ROOT/npm-cache" \
+ "$ROOT/state"
+
+ for tool in launchctl security systemctl; do
+ cat > "$SANDBOX_BIN/$tool" < "$SANDBOX_HOME/.npmrc" </dev/null || true
+ read -r -p "delete it? [y/N] " reply
+ if [[ "$reply" == "y" || "$reply" == "Y" ]]; then
+ rm -rf "$ROOT"
+ echo "deleted $ROOT"
+ else
+ echo "kept $ROOT"
+ fi
+ ;;
+
+ info)
+ provision
+ echo "sandbox root : $ROOT"
+ echo "HOME : $SANDBOX_HOME"
+ echo "HYP_HOME : $SANDBOX_HYP_HOME"
+ echo "npm prefix : $NPM_PREFIX"
+ echo "shims : $SANDBOX_BIN (launchctl, security, systemctl)"
+ echo "spawn mode : $SPAWN (1 = mock launchctl really starts the daemon)"
+ echo "daemon trust : $TRUST_FROM_DAEMON (assumption: whether a background LaunchAgent can raise the macOS password dialog)"
+ echo
+ # Non-login, non-rc bash: a login shell would re-run /etc/profile and
+ # path_helper, which rebuilds PATH and drops the shims out of first place.
+ echo "resolved inside the sandbox:"
+ sandbox_env bash --noprofile --norc -c '
+ hyp_path="$(command -v hyp || true)"
+ echo " hyp : ${hyp_path:-(not installed in the sandbox)}"
+ case "$hyp_path" in
+ "$HYP_SANDBOX_ROOT"/*) ;;
+ "") ;;
+ *) echo " ^ outside the sandbox prefix: this is a hyp from your real PATH" ;;
+ esac
+ echo " launchctl : $(command -v launchctl) (want: $HYP_SANDBOX_ROOT/bin/launchctl)"
+ echo " security : $(command -v security) (want: $HYP_SANDBOX_ROOT/bin/security)"
+ echo " node : $(command -v node) ($(node -v))"'
+ echo
+ echo "sandbox HOME contents:"
+ find "$SANDBOX_HOME" -maxdepth 2 -mindepth 1 -not -path '*/.hyp/*' | sed "s|$SANDBOX_HOME| ~|"
+ ;;
+
+ calls)
+ LIMIT="${1:-30}"
+ CALLS="$ROOT/state/calls.jsonl"
+ if [[ ! -f "$CALLS" ]]; then echo "no intercepted calls yet ($CALLS)"; exit 0; fi
+ tail -n "$LIMIT" "$CALLS" | node -e '
+ let buf = ""
+ process.stdin.on("data", (c) => { buf += c })
+ process.stdin.on("end", () => {
+ for (const line of buf.split("\n").filter(Boolean)) {
+ const e = JSON.parse(line)
+ const note = e.note ? ` # ${e.note}` : ""
+ console.log(`${e.ts} exit ${String(e.exit).padStart(3)} ${e.tool} ${e.args.join(" ")}${note}`)
+ }
+ })
+ '
+ ;;
+
+ state)
+ for f in launchd keychain systemd; do
+ p="$ROOT/state/$f.json"
+ echo "--- $f"
+ if [[ -f "$p" ]]; then cat "$p"; else echo "(empty)"; fi
+ done
+ ;;
+
+ central)
+ provision
+ SUB="${1:-status}"; shift || true
+ CENTRAL_PID="$ROOT/state/central.pid"
+ CENTRAL_LOG="$ROOT/state/central.log"
+ FLEET_CONFIG="$ROOT/state/fleet-config.json"
+ CENTRAL_PORT="${CENTRAL_PORT:-18700}"
+ case "$SUB" in
+ start)
+ if [[ -f "$CENTRAL_PID" ]] && kill -0 "$(cat "$CENTRAL_PID")" 2>/dev/null; then
+ echo "fake central already running (pid $(cat "$CENTRAL_PID")) on http://127.0.0.1:$CENTRAL_PORT"
+ exit 0
+ fi
+ if [[ ! -f "$FLEET_CONFIG" ]]; then
+ cat > "$FLEET_CONFIG" <> "$CENTRAL_LOG" 2>&1 &
+ echo $! > "$CENTRAL_PID"
+ sleep 1
+ echo "fake central: http://127.0.0.1:$CENTRAL_PORT (pid $(cat "$CENTRAL_PID"))"
+ echo "fleet config: $FLEET_CONFIG"
+ ;;
+ stop)
+ if [[ -f "$CENTRAL_PID" ]]; then
+ kill "$(cat "$CENTRAL_PID")" 2>/dev/null || true
+ rm -f "$CENTRAL_PID"
+ echo "stopped fake central"
+ else
+ echo "fake central not running"
+ fi
+ ;;
+ status)
+ if [[ -f "$CENTRAL_PID" ]] && kill -0 "$(cat "$CENTRAL_PID")" 2>/dev/null; then
+ echo "running (pid $(cat "$CENTRAL_PID")) on http://127.0.0.1:$CENTRAL_PORT"
+ curl -s "http://127.0.0.1:$CENTRAL_PORT/_sandbox/counts" && echo
+ else
+ echo "not running"
+ fi
+ ;;
+ log)
+ tail -n "${1:-30}" "$CENTRAL_LOG"
+ ;;
+ config)
+ echo "$FLEET_CONFIG"
+ ;;
+ *)
+ echo "usage: hyp-sandbox central start|stop|status|log [n]|config" >&2
+ exit 64
+ ;;
+ esac
+ ;;
+
+ seed-config)
+ provision
+ SEED_PORT="${1:-18621}"
+ CONFIG="$SANDBOX_HYP_HOME/hypaware-config.json"
+ cat > "$CONFIG" < {
+ if (!node || typeof node !== "object") return
+ for (const [key, value] of Object.entries(node)) {
+ if (key === "listen" && typeof value === "string" && /:\d+$/.test(value)) {
+ node[key] = value.replace(/:\d+$/, `:${port}`)
+ changed += 1
+ } else walk(value)
+ }
+ }
+ walk(config)
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`)
+ console.log(`rewrote ${changed} listen value(s) to port ${port} in ${file}`)
+ if (changed === 0) console.log("no listen key found - the gateway will use its 18521 default and clash with your live daemon")
+ ' "$CONFIG" "$NEW_PORT"
+ ;;
+
+ shell)
+ provision
+ echo "entering sandbox: HOME=$SANDBOX_HOME HYP_HOME=$SANDBOX_HYP_HOME"
+ echo "launchctl/security are mocks. 'exit' to leave."
+ # Deliberately a bare bash: your own shell rc (and macOS path_helper in a
+ # login shell) rebuilds PATH and would push the shims out of first place.
+ sandbox_env bash --noprofile --norc -i
+ ;;
+
+ hyp)
+ provision
+ sandbox_env node "$REPO_ROOT/bin/hypaware.js" "$@"
+ ;;
+
+ run)
+ provision
+ if [[ $# -eq 0 ]]; then echo "usage: hyp-sandbox run [args...]" >&2; exit 64; fi
+ sandbox_env "$@"
+ ;;
+
+ *)
+ echo "unknown command: $CMD" >&2
+ usage
+ exit 64
+ ;;
+esac
diff --git a/scripts/sandbox/lib/fake_central.js b/scripts/sandbox/lib/fake_central.js
new file mode 100644
index 00000000..b7282a14
--- /dev/null
+++ b/scripts/sandbox/lib/fake_central.js
@@ -0,0 +1,275 @@
+// @ts-check
+
+/**
+ * A stand-in central server for the HypAware sandbox.
+ *
+ * `hyp join` enrolls against a central server and the daemon then pulls its
+ * fleet config from it, so any test of join → leave → rejoin needs one. This
+ * speaks the four endpoints the `@hypaware/central` plugin calls:
+ *
+ * - `POST /v1/identity/bootstrap` token → `{ jwt, expires_at }`
+ * - `POST /v1/identity/refresh` bearer → a fresh `{ jwt, expires_at }`
+ * - `GET /v1/config` the fleet config, with an ETag (304 aware)
+ * - `POST /v1/ingest/` accepts and counts NDJSON rows
+ *
+ * The gateway never verifies the JWT signature (it does not share the issuer
+ * secret, see `identity_client.js#decodeJwtSub`), so an unsigned token with a
+ * `sub` claim is enough to be accepted the way a real one would be.
+ *
+ * The fleet config is read from disk on every request, so you can edit it
+ * mid-run (flip `proxy_mode`, change the port) and the daemon picks the new
+ * revision up on its next poll - the ETag is the config's own content hash.
+ *
+ * Usage: `node fake_central.js --port --config --log `
+ */
+
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import http from 'node:http'
+import process from 'node:process'
+
+const args = parseArgs(process.argv.slice(2))
+const port = Number(args.port ?? 18700)
+const configPath = args.config
+const logPath = args.log
+
+if (!configPath) {
+ process.stderr.write('fake_central: --config is required\n')
+ process.exit(64)
+}
+
+const counts = {
+ bootstrap: 0,
+ refresh: 0,
+ config200: 0,
+ config304: 0,
+ ingest: 0,
+ rows: 0,
+ login: 0,
+ sessionRefresh: 0,
+}
+
+/** The org every sign-in resolves to. */
+const ORG = 'sandbox-org'
+
+/** Live authorization codes, minted at /login/start and spent at /token. */
+const codes = new Map()
+let codeSeq = 0
+
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
+ readBody(req).then((body) => {
+ const handled = route(req, res, url, body)
+ log(`${req.method} ${url.pathname} -> ${res.statusCode} ${handled}`)
+ })
+})
+
+server.listen(port, '127.0.0.1', () => {
+ log(`fake central listening on http://127.0.0.1:${port} serving ${configPath}`)
+ process.stdout.write(`fake central listening on http://127.0.0.1:${port}\n`)
+})
+
+process.on('SIGTERM', () => {
+ log(`shutting down; counts=${JSON.stringify(counts)}`)
+ server.close(() => process.exit(0))
+})
+
+/**
+ * @param {http.IncomingMessage} req
+ * @param {http.ServerResponse} res
+ * @param {URL} url
+ * @param {string} body
+ * @returns {string}
+ */
+function route(req, res, url, body) {
+ if (req.method === 'POST' && url.pathname === '/v1/identity/bootstrap') {
+ /** @type {any} */
+ let parsed = {}
+ try { parsed = JSON.parse(body || '{}') } catch { /* reported below */ }
+ const token = parsed.bootstrap_token
+ if (typeof token !== 'string' || token.length === 0) {
+ return json(res, 400, { error: 'bootstrap_token is required' }, 'missing token')
+ }
+ counts.bootstrap += 1
+ return json(res, 200, mintIdentity(), `token=${token.slice(0, 8)}...`)
+ }
+
+ if (req.method === 'POST' && url.pathname === '/v1/identity/refresh') {
+ if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer')
+ counts.refresh += 1
+ return json(res, 200, mintIdentity(), 'refreshed')
+ }
+
+ if (req.method === 'GET' && url.pathname === '/v1/config') {
+ if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer')
+ let document
+ try {
+ document = fs.readFileSync(configPath, 'utf8')
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ return json(res, 500, { error: message }, 'config unreadable')
+ }
+ const etag = `"${crypto.createHash('sha256').update(document).digest('hex').slice(0, 32)}"`
+ if (req.headers['if-none-match'] === etag) {
+ counts.config304 += 1
+ res.writeHead(304, { etag })
+ res.end()
+ return `304 ${etag}`
+ }
+ counts.config200 += 1
+ res.writeHead(200, { 'content-type': 'application/json', etag })
+ res.end(document)
+ return `200 ${etag}`
+ }
+
+ if (req.method === 'POST' && url.pathname.startsWith('/v1/ingest/')) {
+ if (!bearer(req)) return json(res, 401, { error: 'missing bearer' }, 'no bearer')
+ const rows = body.split('\n').filter(Boolean).length
+ counts.ingest += 1
+ counts.rows += rows
+ res.writeHead(202, { 'content-type': 'application/json' })
+ res.end('{}')
+ return `202 rows=${rows} total=${counts.rows}`
+ }
+
+ // The attended `hyp remote login` flow (LLP 0058/0059). The browser is
+ // whatever fetches the start URL: it is answered with a 302 straight to the
+ // client's loopback receiver, so `curl -L ` completes a sign-in.
+ if (req.method === 'GET' && url.pathname === '/v1/identity/login/start') {
+ const redirectUri = url.searchParams.get('redirect_uri')
+ const state = url.searchParams.get('state')
+ if (!redirectUri || !state) {
+ return json(res, 400, { error: 'invalid_request' }, 'missing redirect_uri or state')
+ }
+ const code = `code_${codeSeq += 1}`
+ codes.set(code, { challenge: url.searchParams.get('code_challenge') ?? '' })
+ const location = `${redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
+ res.writeHead(302, { location })
+ res.end()
+ return `302 → ${redirectUri}`
+ }
+
+ if (req.method === 'POST' && url.pathname === '/v1/identity/token') {
+ /** @type {any} */
+ let parsed = {}
+ try { parsed = JSON.parse(body || '{}') } catch { /* reported below */ }
+
+ if (parsed.grant_type === 'refresh_token') {
+ counts.sessionRefresh += 1
+ const access = mintIdentity()
+ return json(res, 200, {
+ access_jwt: access.jwt,
+ expires_at: access.expires_at,
+ org: ORG,
+ }, 'session refreshed')
+ }
+
+ if (parsed.grant_type === 'authorization_code') {
+ if (!codes.delete(parsed.code)) {
+ return json(res, 401, { error: 'invalid_grant', detail: 'unknown or spent code' }, 'bad code')
+ }
+ counts.login += 1
+ const access = mintIdentity()
+ const gateway = mintIdentity()
+ // The gateway_* triple is what makes a login enroll the machine without a
+ // bootstrap token; a partial set is a contract violation the client
+ // rejects loudly, so send all three or none.
+ return json(res, 200, {
+ session_id: `sid_sandbox_${counts.login}`,
+ refresh_token: `refresh_sandbox_${counts.login}`,
+ access_jwt: access.jwt,
+ expires_at: access.expires_at,
+ org: ORG,
+ gateway_jwt: gateway.jwt,
+ gateway_expires_at: gateway.expires_at,
+ gateway_id: 'gw_sandbox_0001',
+ }, `login ok, org ${ORG}`)
+ }
+
+ return json(res, 400, { error: 'unsupported_grant_type' }, `grant ${parsed.grant_type}`)
+ }
+
+ if (req.method === 'GET' && url.pathname === '/_sandbox/counts') {
+ return json(res, 200, counts, 'counts')
+ }
+
+ return json(res, 404, { error: `no route for ${req.method} ${url.pathname}` }, 'UNROUTED')
+}
+
+/**
+ * An unsigned JWT carrying the `sub` the gateway reads for its identity.
+ * @returns {{ jwt: string, expires_at: number }}
+ */
+function mintIdentity() {
+ const expiresAt = Math.floor(Date.now() / 1000) + 86400
+ const header = b64url(JSON.stringify({ alg: 'none', typ: 'JWT' }))
+ const payload = b64url(JSON.stringify({
+ sub: 'gw_sandbox_0001',
+ org: 'sandbox',
+ exp: expiresAt,
+ }))
+ return { jwt: `${header}.${payload}.sandbox`, expires_at: expiresAt }
+}
+
+/**
+ * @param {string} value
+ */
+function b64url(value) {
+ return Buffer.from(value, 'utf8').toString('base64url')
+}
+
+/**
+ * @param {http.IncomingMessage} req
+ */
+function bearer(req) {
+ const header = req.headers.authorization
+ return typeof header === 'string' && header.toLowerCase().startsWith('bearer ')
+}
+
+/**
+ * @param {http.ServerResponse} res
+ * @param {number} status
+ * @param {any} payload
+ * @param {string} note
+ */
+function json(res, status, payload, note) {
+ res.writeHead(status, { 'content-type': 'application/json' })
+ res.end(JSON.stringify(payload))
+ return note
+}
+
+/**
+ * @param {http.IncomingMessage} req
+ * @returns {Promise}
+ */
+function readBody(req) {
+ return new Promise((resolve) => {
+ let body = ''
+ req.on('data', (chunk) => { body += chunk.toString('utf8') })
+ req.on('end', () => resolve(body))
+ })
+}
+
+/**
+ * @param {string} message
+ */
+function log(message) {
+ const line = `${new Date().toISOString()} ${message}\n`
+ if (logPath) fs.appendFileSync(logPath, line)
+}
+
+/**
+ * @param {string[]} argv
+ * @returns {Record}
+ */
+function parseArgs(argv) {
+ /** @type {Record} */
+ const out = {}
+ for (let i = 0; i < argv.length; i += 1) {
+ if (argv[i].startsWith('--')) {
+ out[argv[i].slice(2)] = argv[i + 1]
+ i += 1
+ }
+ }
+ return out
+}
diff --git a/scripts/sandbox/lib/shim.js b/scripts/sandbox/lib/shim.js
new file mode 100644
index 00000000..43af0624
--- /dev/null
+++ b/scripts/sandbox/lib/shim.js
@@ -0,0 +1,712 @@
+// @ts-check
+
+/**
+ * Mock `launchctl`, `security`, and `systemctl` for the HypAware sandbox.
+ *
+ * Every service-manager and keychain call in the kernel goes through one
+ * seam: `runServiceCommand(bin, args)` in `src/core/daemon/service_ops.js`,
+ * which spawns the bare binary name and so resolves it through `PATH`. The
+ * sandbox puts `$HYP_SANDBOX_ROOT/bin` first on `PATH`, and the wrappers
+ * there call into this file.
+ *
+ * The mocks keep their own state on disk so an install/uninstall round trip
+ * behaves like the real thing (bootstrap twice fails, bootout of an unknown
+ * label exits 3, an untrusted cert fails verify-cert) without ever touching
+ * the real launchd domain or the real login keychain.
+ *
+ * Usage: `node shim.js `
+ *
+ * Env:
+ * - `HYP_SANDBOX_ROOT` sandbox root (required)
+ * - `HYP_SANDBOX_SPAWN=1` launchctl really starts the plist's program
+ * - `HYP_SANDBOX_TRUST_REFUSE=1` `security add-trusted-cert` acts like the
+ * user cancelling the macOS password dialog
+ * - `HYP_SANDBOX_TRUST_FROM_DAEMON=grant`
+ * let a daemon-issued `add-trusted-cert` succeed.
+ * The default refuses it: the login keychain is
+ * gated by a password dialog and the daemon is a
+ * background agent with nobody watching. This is
+ * an assumption the sandbox cannot verify, not a
+ * measured fact - see README.md.
+ * - `HYP_SANDBOX_SERVICE=1` set by the supervisor in the daemon it starts,
+ * so the shim can tell a daemon-issued call from
+ * one the user typed. Inherited by its children.
+ * - `HYP_SANDBOX_VERBOSE=1` echo each intercepted call to stderr
+ *
+ * @import {
+ * SandboxKeychainState,
+ * SandboxLaunchdState,
+ * SandboxService,
+ * SandboxSystemdState,
+ * ShimResult,
+ * } from '../../../scripts/sandbox/lib/types.js'
+ */
+
+import { spawn, spawnSync } from 'node:child_process'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+
+const root = process.env.HYP_SANDBOX_ROOT
+if (!root) {
+ process.stderr.write('hyp-sandbox shim: HYP_SANDBOX_ROOT is not set\n')
+ process.exit(64)
+}
+
+const stateDir = path.join(root, 'state')
+const callsPath = path.join(stateDir, 'calls.jsonl')
+const launchdPath = path.join(stateDir, 'launchd.json')
+const keychainPath = path.join(stateDir, 'keychain.json')
+const systemdPath = path.join(stateDir, 'systemd.json')
+
+const tool = process.argv[2]
+const args = process.argv.slice(3)
+
+// `__supervise` is the shim re-entering itself as a KeepAlive supervisor; it
+// never returns, and it must not be recorded as an intercepted call.
+if (tool === '__supervise') {
+ supervise(args[0], args[1])
+} else {
+ main()
+}
+
+function main() {
+ try {
+ if (tool === 'launchctl') return finish(launchctl(args))
+ if (tool === 'security') return finish(security(args))
+ if (tool === 'systemctl') return finish(systemctl(args))
+ return finish({ code: 64, err: `hyp-sandbox shim: unknown tool ${tool}\n` })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ return finish({ code: 70, err: `hyp-sandbox shim: ${tool} failed: ${message}\n` })
+ }
+}
+
+/**
+ * @param {ShimResult} result
+ */
+function finish(result) {
+ record(result)
+ if (result.out) process.stdout.write(result.out)
+ if (result.err) process.stderr.write(result.err)
+ process.exit(result.code)
+}
+
+/**
+ * @param {ShimResult} result
+ */
+function record(result) {
+ const line = JSON.stringify({
+ ts: new Date().toISOString(),
+ tool,
+ args,
+ exit: result.code,
+ note: result.note,
+ cwd: process.cwd(),
+ ppid: process.ppid,
+ })
+ fs.mkdirSync(stateDir, { recursive: true })
+ fs.appendFileSync(callsPath, `${line}\n`)
+ if (process.env.HYP_SANDBOX_VERBOSE === '1') {
+ process.stderr.write(`[sandbox] ${tool} ${args.join(' ')} -> exit ${result.code}\n`)
+ }
+}
+
+/**
+ * @param {string} file
+ * @param {any} fallback
+ */
+function readState(file, fallback) {
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
+ } catch {
+ return fallback
+ }
+}
+
+/**
+ * @param {string} file
+ * @param {any} value
+ */
+function writeState(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
+}
+
+// ---------------------------------------------------------------- launchctl
+
+/**
+ * @param {string[]} argv
+ * @returns {ShimResult}
+ */
+function launchctl(argv) {
+ const sub = argv[0]
+ /** @type {SandboxLaunchdState} */
+ const state = readState(launchdPath, { services: {}, env: {} })
+
+ if (sub === 'setenv') {
+ const [, name, value] = argv
+ state.env[name] = value ?? ''
+ writeState(launchdPath, state)
+ return { code: 0, note: `setenv ${name}` }
+ }
+
+ if (sub === 'unsetenv') {
+ const name = argv[1]
+ delete state.env[name]
+ writeState(launchdPath, state)
+ return { code: 0, note: `unsetenv ${name}` }
+ }
+
+ if (sub === 'getenv') {
+ const name = argv[1]
+ const value = state.env[name]
+ // Real launchctl prints nothing and still exits 0 for an unset variable.
+ return { code: 0, out: value === undefined ? '' : `${value}\n` }
+ }
+
+ if (sub === 'bootstrap') {
+ const plist = argv[argv.length - 1]
+ const label = labelFromPlistFile(plist)
+ if (!label) return { code: 64, err: `Bootstrap failed: 64: unreadable plist ${plist}\n` }
+ // launchd refuses to bootstrap a label already in the domain, running or
+ // not; `installLaunchAgent` boots out first, so this only fires on a real
+ // double-bootstrap.
+ if (state.services[label]) {
+ return { code: 5, err: 'Bootstrap failed: 5: Input/output error\n', note: `already loaded ${label}` }
+ }
+ /** @type {SandboxService} */
+ const service = { label, plist, pid: null, loadedAt: new Date().toISOString() }
+ if (process.env.HYP_SANDBOX_SPAWN === '1') {
+ service.pid = startSupervisor(label, plist)
+ service.supervised = true
+ }
+ state.services[label] = service
+ writeState(launchdPath, state)
+ return { code: 0, note: `bootstrap ${label}${service.pid ? ` pid ${service.pid}` : ' (not spawned)'}` }
+ }
+
+ if (sub === 'bootout') {
+ const label = labelFromTarget(argv[argv.length - 1])
+ const service = label ? state.services[label] : undefined
+ if (!label || !service) return { code: 3, err: 'Boot-out failed: 3: No such process\n' }
+ killService(label, service)
+ delete state.services[label]
+ writeState(launchdPath, state)
+ return { code: 0, note: `bootout ${label}` }
+ }
+
+ if (sub === 'kickstart') {
+ const label = labelFromTarget(argv[argv.length - 1])
+ const service = label ? state.services[label] : undefined
+ if (!label || !service) {
+ return { code: 3, err: `Could not find service "${label}" in domain for\n` }
+ }
+ if (process.env.HYP_SANDBOX_SPAWN === '1') {
+ // `-k` kills the running instance; the supervisor restarts it, which is
+ // what launchd's KeepAlive does.
+ const child = childPid(label)
+ if (child) {
+ try { process.kill(child, 'SIGTERM') } catch { /* already gone */ }
+ } else if (!alivePid(service.pid)) {
+ service.pid = startSupervisor(label, service.plist)
+ writeState(launchdPath, state)
+ }
+ }
+ return { code: 0, note: `kickstart ${label}` }
+ }
+
+ if (sub === 'print') {
+ const label = labelFromTarget(argv[argv.length - 1])
+ const service = label ? state.services[label] : undefined
+ if (!label || !service) {
+ return {
+ code: 113,
+ err: `Could not find service "${label}" in domain for login\n`,
+ }
+ }
+ const child = childPid(label)
+ const running = Boolean(child)
+ const lines = [
+ `${label} = {`,
+ '\tactive count = 1',
+ `\tpath = ${service.plist}`,
+ `\tstate = ${running ? 'running' : 'not running'}`,
+ ]
+ if (running) lines.push(`\tpid = ${child}`)
+ lines.push('\tdomain = sandbox', '}')
+ return { code: 0, out: `${lines.join('\n')}\n` }
+ }
+
+ // Anything else: succeed loudly enough to be visible in `hyp-sandbox calls`.
+ return { code: 0, note: `unhandled launchctl subcommand ${sub}` }
+}
+
+/**
+ * Read the `Label` out of a LaunchAgent plist. Falls back to the filename
+ * so a malformed body still round-trips through bootstrap/bootout.
+ *
+ * @param {string} plist
+ * @returns {string | null}
+ */
+function labelFromPlistFile(plist) {
+ let xml = ''
+ try {
+ xml = fs.readFileSync(plist, 'utf8')
+ } catch {
+ return null
+ }
+ const match = /Label<\/key>\s*([^<]+)<\/string>/.exec(xml)
+ if (match) return match[1].trim()
+ return path.basename(plist).replace(/\.plist$/, '')
+}
+
+/**
+ * Turn a launchctl target into a bare label. Accepts `gui/501/com.foo.bar`,
+ * `user/501/com.foo.bar`, a bare label, or a plist path.
+ *
+ * @param {string} target
+ * @returns {string | null}
+ */
+function labelFromTarget(target) {
+ if (!target) return null
+ if (target.endsWith('.plist')) return labelFromPlistFile(target)
+ const parts = target.split('/')
+ return parts[parts.length - 1] || null
+}
+
+/**
+ * @param {{ pid: number | null }} service
+ */
+function aliveService(service) {
+ return Boolean(service) && alivePid(service.pid)
+}
+
+/**
+ * @param {number | null | undefined} pid
+ */
+function alivePid(pid) {
+ if (!pid) return false
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Stop a service: kill its supervisor, which kills the daemon it is watching.
+ *
+ * @param {string} label
+ * @param {{ pid: number | null }} service
+ */
+function killService(label, service) {
+ const child = childPid(label)
+ if (aliveService(service)) {
+ try { process.kill(/** @type {number} */ (service.pid), 'SIGTERM') } catch { /* gone */ }
+ }
+ // Belt and braces: if the supervisor died without cleaning up, the daemon
+ // it started would otherwise keep the port and the pid file.
+ if (child) {
+ try { process.kill(child, 'SIGTERM') } catch { /* gone */ }
+ }
+ try { fs.rmSync(servicePidPath(label)) } catch { /* nothing to clear */ }
+}
+
+/**
+ * Path of the file the supervisor keeps its current child's pid in.
+ *
+ * @param {string} label
+ */
+function servicePidPath(label) {
+ return path.join(stateDir, `service-${label}.json`)
+}
+
+/**
+ * The pid of the daemon a supervisor is currently running, or null.
+ *
+ * @param {string} label
+ * @returns {number | null}
+ */
+function childPid(label) {
+ const entry = readState(servicePidPath(label), null)
+ if (!entry || !alivePid(entry.pid)) return null
+ return entry.pid
+}
+
+/**
+ * Start a detached supervisor for the plist. Only used when
+ * `HYP_SANDBOX_SPAWN=1`; the default mock records the bootstrap and starts
+ * nothing.
+ *
+ * A plain one-shot spawn is not enough: HypAware applies a pulled config by
+ * exiting and letting launchd's `KeepAlive` bring it back, so a mock without a
+ * supervisor leaves the machine daemon-less exactly when a fleet config lands.
+ *
+ * @param {string} label
+ * @param {string} plist
+ * @returns {number | null}
+ */
+function startSupervisor(label, plist) {
+ const child = spawn(process.execPath, [import.meta.filename, '__supervise', label, plist], {
+ detached: true,
+ stdio: 'ignore',
+ env: process.env,
+ })
+ child.unref()
+ return child.pid ?? null
+}
+
+/**
+ * Run the plist's program, restarting it when it exits, the way launchd's
+ * `KeepAlive` does. Exits when booted out (SIGTERM) or when the program has
+ * crash-looped past the ceiling below.
+ *
+ * Restarts are throttled at 1s rather than launchd's 10s `ThrottleInterval`,
+ * so a restart-driven test does not spend most of its wall clock waiting.
+ *
+ * @param {string} label
+ * @param {string} plist
+ */
+function supervise(label, plist) {
+ const xml = fs.readFileSync(plist, 'utf8')
+ const argv = parsePlistArray(xml, 'ProgramArguments')
+ if (argv.length === 0) process.exit(0)
+ const keepAlive = /KeepAlive<\/key>\s*/.test(xml)
+ // `HYP_SANDBOX_SERVICE` marks everything launchd starts, and is inherited by
+ // whatever the daemon spawns, so the shim can tell "the background agent did
+ // this" from "the user typed this" without walking the process tree.
+ const env = {
+ ...process.env,
+ ...parsePlistDict(xml, 'EnvironmentVariables'),
+ HYP_SANDBOX_SERVICE: '1',
+ }
+ const outPath = parsePlistString(xml, 'StandardOutPath')
+ const errPath = parsePlistString(xml, 'StandardErrorPath')
+ const pidFile = path.join(stateDir, `service-${label}.json`)
+
+ const RESTART_CEILING = 20
+ const RESTART_WINDOW_MS = 60_000
+ const THROTTLE_MS = 1000
+ /** @type {number[]} */
+ const recentStarts = []
+ /** @type {import('node:child_process').ChildProcess | null} */
+ let current = null
+ let stopping = false
+
+ const stop = () => {
+ stopping = true
+ if (current && current.pid) {
+ try { process.kill(current.pid, 'SIGTERM') } catch { /* gone */ }
+ }
+ try { fs.rmSync(pidFile) } catch { /* nothing to clear */ }
+ process.exit(0)
+ }
+ process.on('SIGTERM', stop)
+ process.on('SIGINT', stop)
+
+ const runOnce = () => {
+ if (stopping) return
+ const now = Date.now()
+ recentStarts.push(now)
+ while (recentStarts.length > 0 && now - recentStarts[0] > RESTART_WINDOW_MS) recentStarts.shift()
+ if (recentStarts.length > RESTART_CEILING) {
+ fs.appendFileSync(callsPath, `${JSON.stringify({
+ ts: new Date().toISOString(),
+ tool: 'launchctl',
+ args: ['(supervisor)', label],
+ exit: -1,
+ note: `crash loop: ${recentStarts.length} starts in ${RESTART_WINDOW_MS / 1000}s, giving up`,
+ })}\n`)
+ stop()
+ return
+ }
+ const out = outPath ? openAppend(outPath) : 'ignore'
+ const err = errPath ? openAppend(errPath) : 'ignore'
+ current = spawn(argv[0], argv.slice(1), { stdio: ['ignore', out, err], env })
+ writeState(pidFile, {
+ label,
+ pid: current.pid ?? null,
+ startedAt: new Date().toISOString(),
+ starts: recentStarts.length,
+ })
+ current.on('exit', () => {
+ current = null
+ if (stopping) return
+ if (!keepAlive) {
+ try { fs.rmSync(pidFile) } catch { /* nothing to clear */ }
+ process.exit(0)
+ }
+ setTimeout(runOnce, THROTTLE_MS)
+ })
+ }
+
+ runOnce()
+}
+
+/**
+ * @param {string} file
+ */
+function openAppend(file) {
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ return fs.openSync(file, 'a')
+}
+
+/**
+ * @param {string} xml
+ * @param {string} key
+ * @returns {string[]}
+ */
+function parsePlistArray(xml, key) {
+ const block = sliceAfterKey(xml, key)
+ if (!block) return []
+ const arrayMatch = /([\s\S]*?)<\/array>/.exec(block)
+ if (!arrayMatch) return []
+ return [...arrayMatch[1].matchAll(/([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]))
+}
+
+/**
+ * @param {string} xml
+ * @param {string} key
+ * @returns {Record}
+ */
+function parsePlistDict(xml, key) {
+ const block = sliceAfterKey(xml, key)
+ if (!block) return {}
+ const dictMatch = /([\s\S]*?)<\/dict>/.exec(block)
+ if (!dictMatch) return {}
+ /** @type {Record} */
+ const out = {}
+ const pairs = [...dictMatch[1].matchAll(/([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/g)]
+ for (const pair of pairs) out[unescapeXml(pair[1])] = unescapeXml(pair[2])
+ return out
+}
+
+/**
+ * @param {string} xml
+ * @param {string} key
+ * @returns {string | null}
+ */
+function parsePlistString(xml, key) {
+ const block = sliceAfterKey(xml, key)
+ if (!block) return null
+ const match = /^\s*([\s\S]*?)<\/string>/.exec(block)
+ return match ? unescapeXml(match[1]) : null
+}
+
+/**
+ * @param {string} xml
+ * @param {string} key
+ * @returns {string | null}
+ */
+function sliceAfterKey(xml, key) {
+ const idx = xml.indexOf(`${key}`)
+ if (idx === -1) return null
+ return xml.slice(idx + key.length + 11)
+}
+
+/**
+ * @param {string} value
+ */
+function unescapeXml(value) {
+ return value
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+}
+
+// ------------------------------------------------------------------ security
+
+/**
+ * @param {string[]} argv
+ * @returns {ShimResult}
+ */
+function security(argv) {
+ const sub = argv[0]
+ /** @type {SandboxKeychainState} */
+ const state = readState(keychainPath, { certs: [] })
+
+ if (sub === 'verify-cert') {
+ const certPath = flagValue(argv, '-c')
+ if (!certPath) return { code: 1, err: 'Error: no certificate given\n' }
+ const digest = fileDigest(certPath)
+ const hit = state.certs.find((c) => c.sha256 === digest && c.trusted)
+ if (hit) return { code: 0, out: '...certificate verification successful.\n' }
+ return {
+ code: 1,
+ err: 'Error: certificate verification failed: CSSMERR_TP_NOT_TRUSTED\n',
+ note: 'cert not trusted in sandbox keychain',
+ }
+ }
+
+ if (sub === 'add-trusted-cert') {
+ if (process.env.HYP_SANDBOX_TRUST_REFUSE === '1') {
+ return {
+ code: 1,
+ err: 'SecTrustSettingsSetTrustSettings: User canceled the operation.\n',
+ note: 'simulated dialog cancel',
+ }
+ }
+ // Trusting a CA in the login keychain is gated by the macOS password
+ // dialog, so it needs a human at the session. The attach action runs the
+ // same code in the CLI and in the daemon's reconciler, and the daemon is a
+ // background LaunchAgent with nobody watching - so a mock that always
+ // succeeds makes an unattended fleet setup look like it establishes trust,
+ // which is precisely the wrong answer to take away from a test run.
+ //
+ // Whether real macOS lets a LaunchAgent raise that dialog is NOT settled
+ // (only a second user account or a VM can settle it). The sandbox takes
+ // the pessimistic reading by default and says so; flip it with
+ // `--trust-from-daemon grant` to test the other branch.
+ if (process.env.HYP_SANDBOX_SERVICE === '1' && process.env.HYP_SANDBOX_TRUST_FROM_DAEMON !== 'grant') {
+ return {
+ code: 1,
+ err: 'SecTrustSettingsSetTrustSettings: User interaction is not allowed.\n',
+ note: 'daemon-issued trust refused (sandbox ASSUMPTION: a background LaunchAgent cannot raise the password dialog; --trust-from-daemon grant to assume it can)',
+ }
+ }
+ const certPath = argv[argv.length - 1]
+ const digest = fileDigest(certPath)
+ if (!digest) return { code: 1, err: `SecCertificateCreateFromData: unreadable ${certPath}\n` }
+ const commonName = certCommonName(certPath)
+ const keychain = flagValue(argv, '-k') ?? ''
+ const without = state.certs.filter((c) => c.sha256 !== digest)
+ without.push({
+ cn: commonName,
+ path: certPath,
+ sha256: digest,
+ keychain,
+ trusted: true,
+ addedAt: new Date().toISOString(),
+ })
+ state.certs = without
+ writeState(keychainPath, state)
+ return { code: 0, note: `trusted ${commonName ?? certPath}` }
+ }
+
+ if (sub === 'delete-certificate') {
+ const commonName = flagValue(argv, '-c')
+ const remaining = state.certs.filter((c) => c.cn !== commonName)
+ if (remaining.length === state.certs.length) {
+ return {
+ code: 1,
+ err: 'SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n',
+ }
+ }
+ state.certs = remaining
+ writeState(keychainPath, state)
+ return { code: 0, note: `deleted ${commonName}` }
+ }
+
+ if (sub === 'find-certificate') {
+ const commonName = flagValue(argv, '-c')
+ const hit = state.certs.find((c) => c.cn === commonName)
+ if (!hit) {
+ return {
+ code: 44,
+ err: 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n',
+ }
+ }
+ return { code: 0, out: `keychain: "${hit.keychain}"\n"labl"="${hit.cn}"\n` }
+ }
+
+ return {
+ code: 0,
+ err: `hyp-sandbox: unhandled 'security ${sub}', pretending it succeeded\n`,
+ note: `unhandled security subcommand ${sub}`,
+ }
+}
+
+/**
+ * @param {string[]} argv
+ * @param {string} flag
+ * @returns {string | undefined}
+ */
+function flagValue(argv, flag) {
+ const idx = argv.indexOf(flag)
+ return idx === -1 ? undefined : argv[idx + 1]
+}
+
+/**
+ * @param {string} file
+ * @returns {string | null}
+ */
+function fileDigest(file) {
+ try {
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Best-effort CN extraction. Real `openssl` is on every macOS box; when it is
+ * missing the sandbox falls back to the file path as the identity, which only
+ * costs a `delete-certificate -c ` match.
+ *
+ * @param {string} certPath
+ * @returns {string | null}
+ */
+function certCommonName(certPath) {
+ const res = spawnSync('openssl', ['x509', '-noout', '-subject', '-in', certPath], {
+ encoding: 'utf8',
+ })
+ if (res.status !== 0 || !res.stdout) return null
+ const match = /CN\s*=\s*([^,/\n]+)/.exec(res.stdout)
+ return match ? match[1].trim() : null
+}
+
+// ----------------------------------------------------------------- systemctl
+
+/**
+ * @param {string[]} argv
+ * @returns {ShimResult}
+ */
+function systemctl(argv) {
+ const rest = argv.filter((a) => a !== '--user')
+ const sub = rest[0]
+ const unit = rest[1]
+ /** @type {SandboxSystemdState} */
+ const state = readState(systemdPath, { units: {} })
+
+ if (sub === 'daemon-reload' || sub === 'reset-failed') return { code: 0 }
+
+ if (sub === 'enable' || sub === 'start' || sub === 'restart') {
+ state.units[unit] = { enabled: true, active: true, changedAt: new Date().toISOString() }
+ writeState(systemdPath, state)
+ return { code: 0, note: `${sub} ${unit}` }
+ }
+
+ if (sub === 'stop' || sub === 'disable') {
+ if (state.units[unit]) {
+ state.units[unit].active = false
+ if (sub === 'disable') state.units[unit].enabled = false
+ writeState(systemdPath, state)
+ }
+ return { code: 0, note: `${sub} ${unit}` }
+ }
+
+ if (sub === 'is-active') {
+ const active = Boolean(state.units[unit] && state.units[unit].active)
+ return { code: active ? 0 : 3, out: `${active ? 'active' : 'inactive'}\n` }
+ }
+
+ if (sub === 'is-enabled') {
+ const enabled = Boolean(state.units[unit] && state.units[unit].enabled)
+ return { code: enabled ? 0 : 1, out: `${enabled ? 'enabled' : 'disabled'}\n` }
+ }
+
+ if (sub === 'show') {
+ const entry = state.units[unit]
+ return {
+ code: 0,
+ out: `MainPID=0\nActiveState=${entry && entry.active ? 'active' : 'inactive'}\n`,
+ }
+ }
+
+ return { code: 0, note: `unhandled systemctl subcommand ${sub}` }
+}
diff --git a/scripts/sandbox/lib/types.d.ts b/scripts/sandbox/lib/types.d.ts
new file mode 100644
index 00000000..dea4f146
--- /dev/null
+++ b/scripts/sandbox/lib/types.d.ts
@@ -0,0 +1,61 @@
+/**
+ * Types for the sandbox's mock `launchctl` / `security` / `systemctl`
+ * (`shim.js`). Dev tooling only: none of this ships in the package.
+ */
+
+/** What one intercepted call returns to the caller. */
+export interface ShimResult {
+ /** Process exit code. */
+ code: number
+ /** Text to write to stdout. */
+ out?: string
+ /** Text to write to stderr. */
+ err?: string
+ /** Short human note, recorded in `calls.jsonl` and shown by `hyp-sandbox calls`. */
+ note?: string
+}
+
+/** One bootstrapped LaunchAgent in the mock launchd domain. */
+export interface SandboxService {
+ label: string
+ plist: string
+ /** The supervisor's pid, or null when the mock recorded the bootstrap without spawning. */
+ pid: number | null
+ loadedAt: string
+ /** True when a KeepAlive supervisor is watching the program. */
+ supervised?: boolean
+}
+
+/** The mock launchd domain: bootstrapped services plus the user environment. */
+export interface SandboxLaunchdState {
+ services: Record
+ env: Record
+}
+
+/** One certificate trusted in the mock login keychain. */
+export interface SandboxCert {
+ /** Common name, read with `openssl` when it is available. */
+ cn: string | null
+ path: string
+ sha256: string
+ keychain: string
+ trusted: boolean
+ addedAt: string
+}
+
+/** The mock login keychain. */
+export interface SandboxKeychainState {
+ certs: SandboxCert[]
+}
+
+/** One systemd user unit in the mock. */
+export interface SandboxUnit {
+ enabled: boolean
+ active: boolean
+ changedAt: string
+}
+
+/** The mock systemd user manager. */
+export interface SandboxSystemdState {
+ units: Record
+}
diff --git a/test/core/sandbox-shim.test.js b/test/core/sandbox-shim.test.js
new file mode 100644
index 00000000..bed44adc
--- /dev/null
+++ b/test/core/sandbox-shim.test.js
@@ -0,0 +1,236 @@
+// @ts-check
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+const SHIM = fileURLToPath(new URL('../../scripts/sandbox/lib/shim.js', import.meta.url))
+
+/**
+ * The sandbox's mock `launchctl` / `security` (`scripts/sandbox/lib/shim.js`)
+ * is what keeps a sandboxed `hyp daemon install` or `hyp attach` off the real
+ * launchd domain and out of the real login keychain, so the contract it
+ * presents to `runServiceCommand` is worth pinning: the exit codes the kernel
+ * branches on (bootout's 3, print's 113, bootstrap's transient 5) and the
+ * trust round trip attach reads its mode from.
+ *
+ * Every case runs the shim as a child process, the way the PATH wrappers do.
+ */
+
+/**
+ * Run the shim once against `root`.
+ *
+ * @param {string} root
+ * @param {string} tool
+ * @param {string[]} args
+ * @param {Record} [env]
+ */
+function shim(root, tool, args, env = {}) {
+ const result = spawnSync(process.execPath, [SHIM, tool, ...args], {
+ encoding: 'utf8',
+ env: { ...process.env, HYP_SANDBOX_ROOT: root, ...env },
+ })
+ return { code: result.status, stdout: result.stdout, stderr: result.stderr }
+}
+
+/**
+ * A sandbox root with a LaunchAgent plist in it, removed when the test ends.
+ *
+ * @param {import('node:test').TestContext} t
+ * @param {string} [label]
+ */
+function sandboxRoot(t, label = 'com.hyperparam.hypaware.test') {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-shim-test-'))
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }))
+ const plist = path.join(root, `${label}.plist`)
+ fs.writeFileSync(plist, [
+ '',
+ '',
+ '',
+ ' Label',
+ ` ${label}`,
+ ' ProgramArguments',
+ ' ',
+ ' /usr/bin/true',
+ ' ',
+ '',
+ '',
+ '',
+ ].join('\n'))
+ return { root, plist, label, target: `gui/501/${label}` }
+}
+
+test('launchctl mock: bootstrap → print → bootout round trip', (t) => {
+ const { root, plist, label, target } = sandboxRoot(t)
+
+ assert.equal(shim(root, 'launchctl', ['print', target]).code, 113, 'unknown service prints 113')
+
+ assert.equal(shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code, 0)
+
+ const printed = shim(root, 'launchctl', ['print', target])
+ assert.equal(printed.code, 0)
+ assert.match(printed.stdout, new RegExp(`^${label} = \\{`), 'print reports the label launchd would')
+
+ // Not spawned (HYP_SANDBOX_SPAWN unset), so there is no pid to report.
+ assert.doesNotMatch(printed.stdout, /\bpid = \d+/)
+
+ assert.equal(
+ shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code,
+ 5,
+ 'a second bootstrap of a loaded label is launchd error 5'
+ )
+
+ assert.equal(shim(root, 'launchctl', ['bootout', target]).code, 0)
+ assert.equal(shim(root, 'launchctl', ['bootout', target]).code, 3, 'bootout of an absent service is 3')
+ assert.equal(shim(root, 'launchctl', ['print', target]).code, 113)
+})
+
+test('launchctl mock: the label comes from the plist body, not the filename', (t) => {
+ const { root } = sandboxRoot(t)
+ const plist = path.join(root, 'misnamed.plist')
+ fs.writeFileSync(plist, [
+ '',
+ 'Labelcom.example.real',
+ '',
+ '',
+ ].join('\n'))
+
+ assert.equal(shim(root, 'launchctl', ['bootstrap', 'gui/501', plist]).code, 0)
+ assert.equal(shim(root, 'launchctl', ['print', 'gui/501/com.example.real']).code, 0)
+})
+
+test('launchctl mock: setenv / getenv / unsetenv', (t) => {
+ const { root } = sandboxRoot(t)
+
+ const unset = shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA'])
+ assert.equal(unset.code, 0, 'real launchctl exits 0 for an unset variable')
+ assert.equal(unset.stdout, '')
+
+ assert.equal(shim(root, 'launchctl', ['setenv', 'NODE_USE_SYSTEM_CA', '1']).code, 0)
+ assert.equal(shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA']).stdout, '1\n')
+
+ assert.equal(shim(root, 'launchctl', ['unsetenv', 'NODE_USE_SYSTEM_CA']).code, 0)
+ assert.equal(shim(root, 'launchctl', ['getenv', 'NODE_USE_SYSTEM_CA']).stdout, '')
+})
+
+test('security mock: verify → trust → verify → delete round trip', (t) => {
+ const { root } = sandboxRoot(t)
+ const certPath = path.join(root, 'ca-cert.pem')
+ const openssl = spawnSync('openssl', [
+ 'req', '-x509', '-newkey', 'rsa:2048', '-keyout', path.join(root, 'ca-key.pem'),
+ '-out', certPath, '-days', '1', '-nodes', '-subj', '/CN=HypAware Local CA',
+ ], { encoding: 'utf8' })
+ if (openssl.status !== 0) {
+ t.skip('openssl is unavailable, so no certificate to trust')
+ return
+ }
+ const keychain = path.join(root, 'login.keychain-db')
+
+ assert.equal(
+ shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code,
+ 1,
+ 'an untrusted CA fails verify-cert, which is what makes attach pick base-URL mode'
+ )
+
+ assert.equal(
+ shim(root, 'security', ['add-trusted-cert', '-r', 'trustRoot', '-k', keychain, certPath]).code,
+ 0
+ )
+ assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0)
+
+ assert.equal(
+ shim(root, 'security', ['delete-certificate', '-c', 'HypAware Local CA', '-t', keychain]).code,
+ 0
+ )
+ assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 1)
+
+ const missing = shim(root, 'security', ['delete-certificate', '-c', 'HypAware Local CA', '-t', keychain])
+ assert.equal(missing.code, 1)
+ assert.match(missing.stderr, /could not be found/, 'removeCaTrust reads this as already-absent, not an error')
+})
+
+test('security mock: HYP_SANDBOX_TRUST_REFUSE simulates a cancelled password dialog', (t) => {
+ const { root } = sandboxRoot(t)
+ const certPath = path.join(root, 'ca-cert.pem')
+ fs.writeFileSync(certPath, 'not a real certificate, only its bytes matter here\n')
+
+ const refused = shim(
+ root,
+ 'security',
+ ['add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'login.keychain-db'), certPath],
+ { HYP_SANDBOX_TRUST_REFUSE: '1' }
+ )
+ assert.equal(refused.code, 1)
+ assert.match(refused.stderr, /User canceled the operation/)
+ assert.equal(
+ shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code,
+ 1,
+ 'a refused trust leaves nothing behind'
+ )
+})
+
+test('security mock: a daemon-issued trust is refused by default, a user-issued one is not', (t) => {
+ const { root } = sandboxRoot(t)
+ const certPath = path.join(root, 'ca-cert.pem')
+ fs.writeFileSync(certPath, 'stand-in certificate bytes\n')
+ const trustArgs = ['add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'kc.db'), certPath]
+
+ // HYP_SANDBOX_SERVICE marks the subtree the mock launchd started. Trusting a
+ // CA in the login keychain needs the macOS password dialog answered, and a
+ // background agent has nobody watching - so the sandbox refuses it rather
+ // than letting an unattended fleet setup look like it establishes trust.
+ const fromDaemon = shim(root, 'security', trustArgs, { HYP_SANDBOX_SERVICE: '1' })
+ assert.equal(fromDaemon.code, 1)
+ assert.match(fromDaemon.stderr, /User interaction is not allowed/)
+ assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 1)
+
+ // Whether real macOS actually refuses is unproven, so the other branch is
+ // one flag away.
+ const granted = shim(root, 'security', trustArgs, {
+ HYP_SANDBOX_SERVICE: '1',
+ HYP_SANDBOX_TRUST_FROM_DAEMON: 'grant',
+ })
+ assert.equal(granted.code, 0)
+ assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0)
+})
+
+test('security mock: a user-issued trust succeeds with no service marker', (t) => {
+ const { root } = sandboxRoot(t)
+ const certPath = path.join(root, 'ca-cert.pem')
+ fs.writeFileSync(certPath, 'stand-in certificate bytes\n')
+
+ const result = shim(root, 'security', [
+ 'add-trusted-cert', '-r', 'trustRoot', '-k', path.join(root, 'kc.db'), certPath,
+ ])
+ assert.equal(result.code, 0, 'a CLI attach has a human at the dialog')
+ assert.equal(shim(root, 'security', ['verify-cert', '-c', certPath, '-p', 'ssl']).code, 0)
+})
+
+test('shim records every intercepted call', (t) => {
+ const { root, plist, target } = sandboxRoot(t)
+ shim(root, 'launchctl', ['bootstrap', 'gui/501', plist])
+ shim(root, 'launchctl', ['bootout', target])
+
+ const lines = fs.readFileSync(path.join(root, 'state', 'calls.jsonl'), 'utf8')
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => JSON.parse(line))
+
+ assert.equal(lines.length, 2)
+ assert.deepEqual(lines.map((entry) => entry.args[0]), ['bootstrap', 'bootout'])
+ assert.deepEqual(lines.map((entry) => entry.exit), [0, 0])
+})
+
+test('shim refuses to run without a sandbox root', () => {
+ const result = spawnSync(process.execPath, [SHIM, 'launchctl', 'getenv', 'PATH'], {
+ encoding: 'utf8',
+ env: { ...process.env, HYP_SANDBOX_ROOT: '' },
+ })
+ assert.equal(result.status, 64)
+ assert.match(result.stderr, /HYP_SANDBOX_ROOT is not set/)
+})