feat(ios): scope every simctl call to a device set Simlock owns (2/5) - #81
Merged
V3RON merged 2 commits intoSep 4, 2026
Merged
Conversation
Ownership stops being a guess about a name and becomes a fact about a location. `listManaged` reported whatever was called `simlock-`, which adopted a user's identically-named simulator on sight; it now reports what is in the device set this driver created and proved, whatever the devices are called (safety rule 8). The naming survives only where devices are created, as the cosmetic label a human reads in `simctl list`. Every simctl invocation carries `--set <deviceRoot>` through the one spawn site the driver already funnelled them into, so a call cannot silently address the machine's default set. The driver also stops learning where its devices live by parsing `dataPath` out of simctl output and reads the root from config, which is what makes the provenance mark paths derivable without a subprocess on the reclaim path. Construction is asynchronous because it has to be: a driver that has not yet proven its root can address devices it cannot prove are Simlock's. A root that fails validation refuses that platform and lets the daemon serve the other one -- never a fallback to the default device location (safety rule 9). The refusal is reported as a `driver.root-rejected` event at startup and as a `doctor` finding on every run afterwards, since the daemon has been serving without that platform ever since. `Driver` gains `deviceRoot` and `leaseEnvironment()`, both opaque to the core: it carries the root to say where an orphan came from, and forwards the environment to a lease holder without interpreting a key of it (architecture rule 2). Refs: #73, #70 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X
…k at An adversarial review of the previous commit found that a refused root turned `doctor --fix` into a registry wipe for that platform. Doctor builds reality from the drivers it has, so every device of a platform whose driver refused to start read as `registry-device-missing`, and the fix marked each one `deleted`. The report said both "this platform is unobservable" and "its devices are gone" in the same breath, and acted on the second: the simulators stayed on disk with no record left to reach them, which is the permanent leak ADR 0001 opens by describing, reachable with a `chmod` and through a command CLI.md expects to run unattended in CI. A platform with no driver now yields no existence, run-state, provenance or orphan finding at all. This also closes the same hole for a missing Android SDK, which predates the device roots. The fail-closed path it depends on had no executing test: the discovery branch is gated on darwin and CI is Linux, so throwing on its first line passed all 797 tests. Discovery now takes the platform from its context, and the catch, the rethrow, the logged failure and the rejection payload built from a real OwnedRootError are all covered. A `deviceRoot` that is not a string cost the whole daemon, Android included, while an empty or relative one cost only iOS -- two typos one keystroke apart with wildly different blast radius. All three are now the same platform-scoped refusal. A lease against a refused platform said only `NO_DRIVER`, which is what you get on Linux or without Xcode; it now carries the reason, as safety rule 9 promises. The `driver-unavailable` finding says a restart is what retries discovery, so fixing the permissions and re-running `doctor` is no longer a loop with no exit. `#writeMark` wrote both halves with `Promise.all` and claimed in its own comment that a partial write could not read as drift. It could: a failed erasable write left a fresh durable mark, which Doctor reads as `erased` -- Simlock accusing the user of erasing a device Simlock itself erased. The erasable half goes first, so a failure leaves neither. `DriverRejection` pairs each event name with its own payload type, so the wire contract in EVENTS.md is checked where a rejection is written rather than cast away at the bus. A relative SIMLOCK_HOME is resolved once, at the source, instead of silently costing a platform. And a mis-scoped simctl call now fails the suite instead of hanging it. Refs: #73, #70 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X
This was referenced Sep 2, 2026
This was referenced Sep 2, 2026
V3RON
added a commit
that referenced
this pull request
Sep 4, 2026
Third of five stacked PRs implementing [ADR 0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md). Based on #81. 1. [#80](#80) owned device root validation and its ports 2. [#81](#81) iOS: `simctl --set`, membership-based `listManaged` 3. **Android: `ANDROID_AVD_HOME`, private supervised adb server** ← this PR 4. lease environment and the `simctl` / `adb` passthroughs 5. doctor: `--purge-orphans`, legacy pre-root devices, docs status ## What this is Android has no `simctl --set`, so containment is built rather than inherited. AVDs live in a root this driver created and proved; every `adb`, `emulator` and `avdmanager` call carries `ANDROID_AVD_HOME` and `ANDROID_ADB_SERVER_PORT` through one insertion point; console ports move to 5586–5682, above the 5585 ceiling a default adb server scans. `listManaged` answers from membership in that root — a name prefix is not evidence, and neither is being visible to our server. The server is supervised by pid because it has to be: `ADB_REJECT_KILL_SERVER=1` means an agent's reflexive `adb kill-server` cannot detach every leased emulator at once, and neither can Simlock. It runs as `nodaemon server` so the recorded pid is the server rather than a launcher that exits, and `adb-server.json` lives outside `state.json` because a corrupt registry is exactly when a leftover server must still be reapable. An occupied port refuses the platform rather than attaching to whatever is listening, which could be Android Studio's. ## This PR amends ADR decision 4 The record specifies `ADB_LOCAL_TRANSPORT_MAX_PORT=5683` alone. Verified against adb's source, that is **incomplete and actively harmful**: the emulator scan has no minimum-port variable, so the ceiling only widens a sweep that always starts at 5555. ```c for (int port = DEFAULT_ADB_LOCAL_TRANSPORT_PORT; port <= adb_local_transport_max_port; port += 2) connect_emulator(port); // Note, uses port and port-1 ``` Simlock's server would therefore have discovered and connected to *the user's own emulators*, leaving two adb servers contending for one device — the accident this ADR exists to prevent, running the other way. `ADB_EMU=0` is what actually contains it, and our emulators still attach because the emulator announces itself with `host:emulator:<port>`. The ADR carries a dated Correction with the citations; `CONFIGURATION.md` and `known-pitfalls.md` match. Containment is now symmetric: our consoles sit above the user's default ceiling so their server cannot see ours, and the scanner is off so ours never looks at theirs. ## Review round (second commit) An adversarial review found eight defects. The worst was a design flaw, not a slip: **A clean `daemon stop` orphaned every running emulator, permanently.** Devices are *meant* to survive a restart — releasing a lease hands the device to the warm pool. But shutdown reaps the adb server, and an emulator announces itself exactly once, to the server running when it booted. With the scanner off nothing found them again: `adb devices` empty, every running AVD reporting `stopped`, no orphan finding able to name them (several GB of RSS each, forever), and the allocator re-issuing 5586 to a device that then could not bind — the occupancy defect this ADR set out to fix, arriving from the other side. The design defended the crash it anticipated and broke on the orderly shutdown it did not. Simlock now does deliberately, for its own bounded range, what adb's scanner would have done for everyone's: after starting or adopting a server it announces every console port in 5586–5682. That also makes the design independent of whether a running emulator re-announces itself, which cannot be tested here. **The pid identity check would have killed the user's adb server.** `ps -o comm=` strips the arguments that make a server ours, so it proved "some adb" — and on a developer's machine a recycled pid most likely belongs to the user's own server. It now requires an adb binary, our port, and `nodaemon`. Also fixed: a failure after spawn hung the daemon (the child was never `unref`ed) *and* disabled Android permanently, because the record was written after the wait; a disk error writing that record took iOS down too; a spawn that never produced a process crashed the daemon through an unhandled `error` event no caller could catch; the port probe believed any listener, so a cold-start race let the loser clobber the winner's record; and an inconclusive `ps` at shutdown deleted the record while leaving the server alive, turning the first clean shutdown on a host without `ps` into a permanently dead platform. ## Not verified here No macOS, no Android SDK, no `adb` — every branch is unit-tested against fakes. Specifically unverified on real tooling: that `host:emulator:<port>` re-attaches an emulator started under a *previous* server (the restart-recovery path — the first experiment worth running on a Mac); that `ps -o args=` renders the command line as assumed under BSD `ps`; that `unref()` lets the daemon exit with the server running; and both slow lanes. ## Verification `pnpm run check` green: typecheck, typecheck:e2e, lint, format, 865 unit tests, e2e (33 passed, 1 expected fail, 3 skipped — matching baseline). `fallow audit` clean across 28 changed files. Refs #73, #70 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X --- _Generated by [Claude Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
V3RON
added a commit
that referenced
this pull request
Sep 4, 2026
…wrappers (4/5) (#83) Fourth of five stacked PRs implementing [ADR 0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md). Based on #82. 1. [#80](#80) owned device root validation and its ports 2. [#81](#81) iOS: `simctl --set`, membership-based `listManaged` 3. [#82](#82) Android: `ANDROID_AVD_HOME`, private supervised adb server 4. **lease environment and the `simctl` / `adb` passthroughs** ← this PR 5. doctor: `--purge-orphans`, legacy pre-root devices, docs status ## What this is Containment cuts both ways, and PRs 2 and 3 only built the outward half. A simulator in Simlock's device set and an emulator on Simlock's adb server are unreachable with a bare `simctl` or `adb` — which is the point for everyone else, and a problem for the agent holding the lease. Until this lands, `simlock lease --json` returns a `udid` no documented workflow can address (ADR decision 7). - **A grant carries an `environment`** the driver built and the core never reads — the device-set path on iOS, the adb server port on Android. Set at the single `LeaseGrant` construction site, forwarded verbatim, so a third driver contributes its own scoping without a core edit (architecture rule 2). - **`--export-env`** prints it as shell exports for `eval`, quoted so a path with a space or an apostrophe survives the round trip. The e2e test asserts that through a real `/bin/sh`, not a string comparison. - **`simlock simctl` and `simlock adb`** inject the scoping and pass every other argument through. Each driver owns the verbs its wrapper refuses, because handing those back would restore the exact capability this work removes. - The client parses `environment` leniently, and only that field: an older daemon sends none, and a grant is still worth having without one. ## Review round (third commit) An adversarial review found the most serious defect in the stack so far: **`simlock simctl --profiles /tmp erase all` was proxied.** The subcommand was taken as the first argument not starting with `-`, and simctl's two global options put their value in a *separate* argv entry — so `/tmp` read as the subcommand, no refusal fired, and the daemon returned `xcrun simctl --set <simlockRoot> --profiles /tmp erase all`. **Simlock supplied the containment path itself**, erasing every simulator in the root including devices under another agent's live lease. The barrier was supposed to be knowing that path. A caller-supplied `--set`/`--profiles` is now refused outright, which makes "the first non-flag argument is the subcommand" true by construction. Worth stating honestly: `simlock simctl list devices -j` prints `dataPath` for every device, so a *determined* user learns the root anyway, and the ADR says this is not a security boundary. What the refusal buys is the typo-and-reflex guard — and that was broken. **The refusal list was a list of words, not a model of the rule.** It let through verbs worse than the ones it stopped: `simctl shutdown all` (the iOS analogue of `adb kill-server`, which Android refuses for exactly that reason — it stops every device Simlock believes is running, for every agent, burning each lease's recovery budget), `adb emu avd stop`, and `adb emu avd snapshot delete` (which destroys the baseline every later reclaim restores from, silently degrading reclaims from seconds to a full wipe). Bare tooling cannot reach any of those devices, so each was a returned capability. `simctl runtime delete` is not in that class but is now refused too — a wrapper offered as the safe path should not proxy something it cannot undo and Simlock will not re-download. Also fixed: `--export-env` escaped values against every pathological input and did not escape keys at all, so a driver-supplied key could carry a command into the `eval` the flag exists for (confirmed by execution); and against an older daemon it exited 0 having printed nothing, leaving a lease committed, TTL-bound and unnameable. ## Not amended ADR decision 7's `(delete, erase, create, emu kill)` is illustrative; the added refusals apply that decision rather than change it, so the record stands as written. ## Worth your judgement **The passthrough requires no lease.** Any local process can `simlock adb shell …` against a device another agent holds. That may be intended operator convenience, but it makes the refusal list the only thing between one agent and another agent's device. ## Verification `pnpm run check` green: typecheck, typecheck:e2e, lint, format, 927 unit tests, e2e 38 passed / 1 expected fail / 3 skipped (five new cases, including the real-`/bin/sh` export round trip). `fallow audit` clean across 34 changed files. The `slow-*` lanes gained passthrough and environment assertions but **cannot run here** — no macOS, no Xcode, no Android SDK. One unrelated defect surfaced and was deliberately not absorbed: `daemon stop` does not exit while a detached lease is outstanding. It reproduces on `feat/owned-device-roots` with none of this stack applied; the new e2e test releases its lease with a comment pointing at it. Refs #73, #70 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X --- _Generated by [Claude Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
V3RON
added a commit
that referenced
this pull request
Sep 4, 2026
Fifth of five stacked PRs implementing [ADR 0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md). Based on #83. **This is the PR that marks the ADR implemented.** 1. [#80](#80) owned device root validation and its ports 2. [#81](#81) iOS: `simctl --set`, membership-based `listManaged` 3. [#82](#82) Android: `ANDROID_AVD_HOME`, private supervised adb server 4. [#83](#83) lease environment and the scoped wrappers 5. **doctor: `--purge-orphans`, legacy pre-root devices, docs status** ← this PR ## What this is Every earlier PR made Simlock able to *prove* what it owns. This one makes it *destroy* on the strength of that proof, which is why the wiring matters more than the feature. **`doctor --purge-orphans`** reclaims a device that sits inside a validly-marked root with no registry record — almost always a daemon that died between creating a device and writing it down, and until now permanently unreclaimable, because registry-only destruction cannot reach what the registry has never heard of. It is the single opt-in exception safety rule 1 allows, and it is deliberately hard to reach by accident: its own flag rather than part of `--fix` (so a `doctor --fix` already running unattended in CI does not acquire a destructive behaviour on upgrade), a confirmation unless `--yes`, and no reachability from the reaper, a cleanup rule, or an idle tier. The reason it must stay opt-in is structural. Every central safety filter is written over registry records, and an orphan has none — so an orphan proposal bypasses the safety net rather than passing through it, including a device this very daemon is mid-provision on. That reasoning is written at the purge site. **Roots are re-proven before anything is destroyed.** Ownership is established once at startup and then trusted for the life of the process — fine for reporting, not fine for destroying, since a daemon up for days is one `mv` or symlink away from a root that now holds the user's own simulators. Every root a purge would touch is re-validated first via a new `Driver.revalidateRoot()`, and a refusal aborts the *whole* run rather than one platform, so a half-purge is impossible rather than merely unlikely. **Legacy pre-root devices** are reported as `legacy-device` rather than as vanished, and `--fix` destroys them through their old unscoped path. That is the one destruction that reaches outside an owned root, and it is permitted because the device is in the registry: registry-only destruction is satisfied by the record, not by the root (ADR Migration paragraph). ## Docs stop describing a future ADR 0001 status → `Accepted`; `device.orphan-purged` → `implemented`; the `(planned)` markers and "after ADR 0001 lands" notes out of `known-pitfalls.md`. Two doc-truth findings surfaced while flipping the status: - **`docs/ARCHITECTURE.md` was made untrue by this PR.** It said "conversely Simlock cannot address anything outside [the root]", which `destroyLegacy` breaks. Amended with the migration exception and the startup-proof / re-proof split rather than left standing as a false statement. - **A new pitfall entry**: a root's ownership is proven at startup and trusted for the daemon's life. The existing "accident boundary, not a security boundary" entry covers someone deliberately passing `--set`; it said nothing about the root being replaced under a running daemon. Nothing in the ADR's decisions needed changing, which is why the status flip is honest. ## Deliberate gaps - `DoctorReport`'s shape is unchanged, so a confirmed purge that was refused by root re-validation signals that only through the orphan findings still being present, plus a `daemon.log` line. An explicit "purge refused" in the report would be a reasonable follow-up. - `destroyLegacy` does not stop a running legacy emulator — doing so would mean driving the user's own adb server. - `docs/EVENTS.md` still carries a `> Status: **planned catalog**` header even though every row now reads `implemented`. Left alone as out of scope; worth a separate tidy. ## Verification `pnpm run check` green: typecheck, typecheck:e2e, lint, format, 949 unit tests, e2e 40 passed / 1 expected fail / 3 skipped (two new fake-driver flows: a purge with its confirmation gate, and a refused root destroying nothing). `fallow audit` clean across 24 changed files. Nothing in this PR has been exercised against a real `simctl` or `avdmanager` — no macOS, no Xcode, no Android SDK here. The `slow-*` lanes were reviewed and needed no change; they remain unrun. Refs #73, #70 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X --- _Generated by [Claude Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_ Co-authored-by: Claude <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.
Second of five stacked PRs implementing ADR 0001. Based on #80.
simctl --set, membership-basedlistManaged, fail-closed discovery ← this PRANDROID_AVD_HOME, private supervised adb serversimctl/adbpassthroughs--purge-orphans, legacy pre-root devices, docs statusWhat this is
Ownership stops being a guess about a name and becomes a fact about a location.
listManagedanswers from set membership. It used to report whatever was calledsimlock-, which adopts a user's identically-named simulator on sight. The naming survives only where devices are created, as the cosmetic label a human reads insimctl list(safety rule 8).simctlinvocation carries--set <deviceRoot>, through the one spawn site the driver already funnelled them into.dataPathout of simctl output and reads the root from config, which is what lets the provenance mark paths be derived without a subprocess on the reclaim path.driver.root-rejectedevent at startup and adoctorfinding on every run after.DrivergainsdeviceRootandleaseEnvironment(), both opaque to the core (architecture rule 2). Android gets placeholder values here; PR 3 replaces them.Review round (second commit)
An adversarial review found ten defects. The worst two:
doctor --fixdeleted a platform's entire registry inventory when its driver refused to start. Doctor builds reality from the drivers it has, so every device of a dark platform read asregistry-device-missingand--fixmarked each onedeleted. The report contained bothdriver-unavailable("this platform is unobservable") andregistry-device-missing("its devices are gone") and acted on the second — leaving the simulators on disk with no registry record able to reach them, which is the permanent multi-gigabyte leak the ADR opens by describing, reachable with achmod, through a commanddocs/CLI.mdexpects to run unattended in CI. You cannot conclude "the device is missing" from "I could not look." A platform with no driver now produces no existence, run-state, provenance or orphan finding at all. Gated on driver presence, so it also closes the same hole for a missing Android SDK, which predates this stack.The fail-closed path had no executing test.
throw new Error("MUTANT")on the first line ofdiscoverIosDriverpassed all 797 tests, because the branch is gated onprocess.platform === "darwin"and CI is Linux. Discovery now takes the platform from its context, and the catch, the rethrow, the log and the rejection payload — built from a realOwnedRootError, so renamingroottopathin the published payload now fails CI — are all covered.Also fixed: a non-string
deviceRoottook down the whole daemon including Android while""and a relative path cost only iOS;NO_DRIVERon a refused platform reported no reason despite safety rule 9 promising one;#writeMark'sPromise.allcould leave a durable mark without its erasable half, making Doctor accuse the user of an erase Simlock itself performed; the runtime event-payload cast would have let PR 3 emit an Android payload contradictingdocs/EVENTS.md; a relativeSIMLOCK_HOMEsilently disabled iOS; and a mis-scopedsimctlcall made the suite hang for 100s instead of failing in 2ms.Known residuals
mv, a symlink) would havelistManagedreport the user's own simulators as ours. PR 5 re-validates before--purge-orphansdestroys anything — reporting can live with a stale proof, destroying cannot — and documents the gap inknown-pitfalls.md.driver-unavailableis a startup snapshot, re-reported until the daemon restarts. The finding now says so.Verification
pnpm run checkgreen in full: typecheck, typecheck:e2e, lint, format, 811 unit tests, and the e2e lane (33 passed, 1 expected fail, 3 skipped — matching the pre-change baseline).pnpm exec fallow auditclean across 23 changed files.Refs #73, #70
🤖 Generated with Claude Code
https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X
Generated by Claude Code