From b8f762c7758a758e9ee59debb978a77f6088966e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:01:45 +0200 Subject: [PATCH 1/7] docs(adr): 0048 an iOS worker may keep its simulators in Xcode's device set (proposed) --- ...eep-its-simulators-in-xcodes-device-set.md | 94 +++++++++++++++++++ docs/internal/adr/README.md | 3 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md new file mode 100644 index 000000000..912c78f59 --- /dev/null +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -0,0 +1,94 @@ +# 0048. An iOS worker may keep its simulators in Xcode's device set + +- **Status:** Proposed +- **Date:** 2026-10-23 +- **Issue:** [#962](https://github.com/callstackincubator/simlock/issues/962), from request [#946](https://github.com/callstackincubator/simlock/issues/946) +- **Narrows:** ADR 0001 §2, §3, §5 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged. + +## Context + +ADR 0001 puts every Simlock simulator in its own CoreSimulator device set, and every `simctl` call carries `--set ` (`src/drivers/ios/index.ts:1566` at `3de46d1ff`). Being in the set is what proves a device is Simlock's (`src/drivers/ios/index.ts:726-729`). It is an accident boundary, not a security boundary (`src/drivers/AGENTS.md:44-46`). + +The set has a cost ADR 0001 did not weigh. `xcodebuild -destination "platform=iOS Simulator,id="` finds a simulator outside Xcode's own set only through the per-user default `DVTSimulatorSetLocation`. This was checked on Xcode 26.4.1, 2026-09-14. Every per-process route fails: a `-DVTSimulatorSetLocation` argument, an environment variable of that name, `HOME` or `CFFIXED_USER_HOME` redirection (with or without `__CFPREFERENCES_AVOID_DAEMON=1`), and `DVT_TOOLCHAIN_USER_DEFAULTS_SUFFIX`. agent-device's XCTest runner passes the argument route (`packages/platform-apple/src/runner/runner-device-set.ts:24-26` at agent-device `v0.21.28`), so it cannot drive a Simlock simulator either. Neither can `react-native run-ios --udid`, Expo `run:ios`, Detox or Maestro. Today the only route is to build for `generic/platform=iOS Simulator` and install with `simlock simctl install`. + +Android has no such cost: `adb`, Gradle and the React Native CLI honour `ANDROID_ADB_SERVER_PORT`, which the grant exports. + +What the set still guards against: `xcrun simctl shutdown all`, `erase all`, `delete unavailable`, Xcode's "Delete All Simulators", and a tool that picks `booted`. What protects a user's own simulators whatever the set: cleanup, `nuke` and the warm pool act only on registry devices (`docs/internal/ARCHITECTURE.md:112-113`). A leased device shut down from outside is rebooted under its lease. A missing one ends its lease as `device-missing` (`src/leasing/lease-health-monitor.ts:216-219`). An erased one ends its lease as `provenance-drift` once it runs again (`src/leasing/lease-health-monitor.ts:238-240`). ADR 0034 runs one worker per OS user, so Xcode's set has one Simlock owner per machine. + +## Decision + +### 1. One driver key chooses the set + +`drivers.ios.deviceSet` is `"simlock"` (the default) or `"default"`. + +- `simlock`: ADR 0001 as today. +- `default`: Simlock's simulators live in Xcode's set, `~/Library/Developer/CoreSimulator/Devices`, and `simctl` runs without `--set`. + +Any other value, or `drivers.ios.deviceRoot` set together with `"default"`, turns iOS off at startup with a `driver.root-rejected` reason `invalid-device-set`. Android keeps running, and `simlock doctor` shows the reason. That is how a bad `deviceRoot` behaves today (`src/daemon/main.ts:1404-1409`). The key is read at daemon start, like every other key. + +The per-user `DVTSimulatorSetLocation` default is not a third value, and Simlock never writes it (see Alternatives). + +### 2. Under `default`, a device's own mark proves it is Simlock's + +In Xcode's set, being there proves nothing: the user's own simulators are there too. So under `default`, `listManaged` reports only devices whose folder holds Simlock's durable mark, the one `provision` already writes (`src/drivers/ios/index.ts:741`, `1449-1462`). A device without it is never listed, never an orphan, and never reached by `doctor --purge-orphans`. The root marker of ADR 0001 §2 does not apply to Xcode's set, and Simlock never writes into that folder outside a device it created. + +The same mark keeps a user's simulators apart in the disk report too: a device without the mark counts as the user's, and one with it counts as Simlock's. + +### 3. A changed setting refuses iOS until the operator clears the old devices + +At startup the iOS driver looks where the other setting keeps devices: + +- under `default`: any device in Simlock's own set; +- under `simlock`: any device in Xcode's set that carries Simlock's durable mark. + +If it finds one, iOS does not start (`driver.root-rejected`, reason `device-set-changed`). The message tells the operator to set the key back, run `simlock nuke --delete-devices`, then switch again. Simlock does not move or recreate devices: a switch is rare, and documented operator steps cost less than a migration (the same trade as ADR 0036's upgrade step). + +### 4. Under `default`, nothing names a set + +The grant carries no `SIMLOCK_IOS_DEVICE_SET`. Neither does the screen-capture program, which then searches Xcode's set (`vendor/screen-capture/Sources/simlock-screen-capture/FrameCapture.swift:417-426`). The agent-device simulator daemon gets no `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET`, so its `simctl` calls and its XCTest runner use Xcode's set (`packages/kernel/src/device.ts:346-348`, `packages/platform-apple/src/runner/runner-device-set.ts:24-25` at `v0.21.28`). A client's own `--ios-simulator-device-set` is still refused. + +### 5. `simlock simctl` keeps its refusals + +Under `default` the wrapper adds no `--set`, and it still refuses `create`, `erase`, `delete` and a caller's `--set` or `--profiles`. An agent told to use the wrapper stays guarded. An agent that runs `xcrun simctl` directly is not, and the docs say what `shutdown all` and `erase all` then do to a leased device. + +### 6. When to flip the default + +The default stays `simlock`. Flip it to `default` once both of these hold: + +- recovery after an external `shutdown all` and an external `erase all` has a slow-lane test; +- the docs have stated for at least one release what those commands do to a leased device. + +```mermaid +flowchart LR + subgraph simlock["deviceSet: simlock (today)"] + S1[simctl --set root] --> S2[Simlock's set] + X1[xcodebuild id=UDID] -.not found.-> S2 + end + subgraph default["deviceSet: default"] + D1[simctl, no --set] --> D2[Xcode's set] + X2[xcodebuild id=UDID] --> D2 + D2 --> M{durable mark?} + M -->|yes| O[Simlock's device] + M -->|no| U[user's device: never touched] + end +``` + +## Consequences + +- `src/drivers/ios/AGENTS.md`'s "Never `simctl` without `--set`" holds under `simlock` only. +- Under `default`, a worker's simulators show in Xcode's destination picker and in plain `xcrun simctl list`. +- A careless `xcrun simctl shutdown all` reboots leased devices under their leases. A careless `erase all` ends their leases as `provenance-drift`, and the holder loses installed apps. +- `driver.root-rejected` gains two reasons, `invalid-device-set` and `device-set-changed`; `docs/internal/EVENTS.md` and the website's cleanup page list them. +- The website gains a "Build and run your app" page: the generic build and install under `simlock`, direct Xcode targeting under `default`, and the adb serial on Android. + +## Alternatives considered + +- **Write `DVTSimulatorSetLocation` for the user.** Rejected by the maintainer on 2026-09-14 and again on 2026-10-23. It applies to every `xcodebuild` run of that user, and it hides the user's own simulators while it is set. +- **Make `default` the default now.** Rejected for now: recovery after an external erase has no slow-lane test yet. §6 says when to flip it. +- **Migrate devices when the setting changes.** Rejected: rare, and a lot of code for one switch. §3's refusal and steps are enough. +- **Under `default`, make `simlock simctl` a plain passthrough.** Rejected: agents told to use it would lose their guard for nothing. +- **Prove ownership by the `simlock-` name prefix.** Rejected: a name is cosmetic and anyone can copy it. The durable mark is written only by `provision`. + +## Verification + +The iOS driver's tests drive both settings with a fake `simctl`: the argv, `listManaged` with marked and unmarked devices, the two refusals, the grant environment, and the passthrough. The agent-device supervisor's tests check the daemon environment. A slow-lane test leases a simulator under `default`, finds it with plain `xcrun simctl list`, and drives it through agent-device. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index 757b4f4e9..a57b76903 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -39,7 +39,7 @@ the status is stale. | ADR | Title | Status | |---|---|---| -| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`) | +| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`); §2, §3, §5 and §7 narrowed by 0048 for iOS under `deviceSet: "default"` | | [0002](0002-opt-in-slim-ios-simulators.md) | Opt-in slim iOS simulators | Accepted | | [0003](0003-one-typed-daemon-contract-behind-every-frontend.md) | One typed daemon contract behind every frontend | Accepted | | [0004](0004-ttl-first-leases-on-every-transport.md) | TTL-first leases on every transport | Accepted — partly implemented: §2 (the CLI exits when its socket drops instead of keeping its lease) | @@ -84,3 +84,4 @@ the status is stale. | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | | [0044](0044-the-console-reads-what-its-page-shows-and-events-mark-it-stale.md) | The console reads what its page shows, and events mark it stale | Accepted — not yet implemented; supersedes 0013 §3 | | [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | +| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Proposed; narrows 0001 §2, §3, §5 and §7 for iOS under `deviceSet: "default"` | From e65ff7968ff4887946c4aaca5b0d7db98b1fb15b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:26:20 +0200 Subject: [PATCH 2/7] docs(adr): 0048 records Xcode 27's per-command device set --- ...-worker-may-keep-its-simulators-in-xcodes-device-set.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index 912c78f59..69cb563c8 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -9,7 +9,9 @@ ADR 0001 puts every Simlock simulator in its own CoreSimulator device set, and every `simctl` call carries `--set ` (`src/drivers/ios/index.ts:1566` at `3de46d1ff`). Being in the set is what proves a device is Simlock's (`src/drivers/ios/index.ts:726-729`). It is an accident boundary, not a security boundary (`src/drivers/AGENTS.md:44-46`). -The set has a cost ADR 0001 did not weigh. `xcodebuild -destination "platform=iOS Simulator,id="` finds a simulator outside Xcode's own set only through the per-user default `DVTSimulatorSetLocation`. This was checked on Xcode 26.4.1, 2026-09-14. Every per-process route fails: a `-DVTSimulatorSetLocation` argument, an environment variable of that name, `HOME` or `CFFIXED_USER_HOME` redirection (with or without `__CFPREFERENCES_AVOID_DAEMON=1`), and `DVT_TOOLCHAIN_USER_DEFAULTS_SUFFIX`. agent-device's XCTest runner passes the argument route (`packages/platform-apple/src/runner/runner-device-set.ts:24-26` at agent-device `v0.21.28`), so it cannot drive a Simlock simulator either. Neither can `react-native run-ios --udid`, Expo `run:ios`, Detox or Maestro. Today the only route is to build for `generic/platform=iOS Simulator` and install with `simlock simctl install`. +The set has a cost ADR 0001 did not weigh. `xcodebuild -destination "platform=iOS Simulator,id="` finds a simulator outside Xcode's own set only through the per-user default `DVTSimulatorSetLocation`. This was checked on Xcode 26.4.1, 2026-09-14. Every per-process route fails: a `-DVTSimulatorSetLocation` argument, an environment variable of that name, `HOME` or `CFFIXED_USER_HOME` redirection (with or without `__CFPREFERENCES_AVOID_DAEMON=1`), and `DVT_TOOLCHAIN_USER_DEFAULTS_SUFFIX`. agent-device's XCTest runner passes the argument route (`packages/platform-apple/src/runner/runner-device-set.ts:24-26` at agent-device `v0.21.28`), so it cannot drive a Simlock simulator either. Neither can `react-native run-ios --udid`, Expo `run:ios`, Detox or Maestro. On that Xcode the only route is to build for `generic/platform=iOS Simulator` and install with `simlock simctl install`. + +Xcode 27.0 changed this, checked 2026-10-23 with a simulator in a temporary set: `xcodebuild build -destination "platform=iOS Simulator,id="` fails with "Unable to find a device matching the provided destination specifier", and the same command with `-DVTSimulatorSetLocation=` prints `** BUILD SUCCEEDED **`. The argument applies to that one command: `-showdestinations` with it lists only that set's simulators, and no user default is written. agent-device's runner passes the same argument. A tool that lets the caller add `xcodebuild` arguments can then reach Simlock's set (`react-native run-ios --extra-params`). One that does not, such as Expo `run:ios`, still cannot. Simlock names no minimum Xcode version (`website/src/content/docs/docs/introduction/requirements.mdx:15`). Android has no such cost: `adb`, Gradle and the React Native CLI honour `ANDROID_ADB_SERVER_PORT`, which the grant exports. @@ -79,11 +81,12 @@ flowchart LR - Under `default`, a worker's simulators show in Xcode's destination picker and in plain `xcrun simctl list`. - A careless `xcrun simctl shutdown all` reboots leased devices under their leases. A careless `erase all` ends their leases as `provenance-drift`, and the holder loses installed apps. - `driver.root-rejected` gains two reasons, `invalid-device-set` and `device-set-changed`; `docs/internal/EVENTS.md` and the website's cleanup page list them. -- The website gains a "Build and run your app" page: the generic build and install under `simlock`, direct Xcode targeting under `default`, and the adb serial on Android. +- The website gains a "Build and run your app" page. Under `simlock`: the `-DVTSimulatorSetLocation="$SIMLOCK_IOS_DEVICE_SET"` argument on Xcode 27 or later, and the generic build and install on older Xcode or with a tool that takes no `xcodebuild` arguments. Under `default`: direct targeting by UDID. On Android: the adb serial. ## Alternatives considered - **Write `DVTSimulatorSetLocation` for the user.** Rejected by the maintainer on 2026-09-14 and again on 2026-10-23. It applies to every `xcodebuild` run of that user, and it hides the user's own simulators while it is set. +- **No switch: Xcode 27's per-command argument is enough.** Rejected: it does not help older Xcode, or a tool that takes no `xcodebuild` arguments (Expo `run:ios`). The docs give the argument as the first route under `simlock`. - **Make `default` the default now.** Rejected for now: recovery after an external erase has no slow-lane test yet. §6 says when to flip it. - **Migrate devices when the setting changes.** Rejected: rare, and a lot of code for one switch. §3's refusal and steps are enough. - **Under `default`, make `simlock simctl` a plain passthrough.** Rejected: agents told to use it would lose their guard for nothing. From fa77e355eac2c3329bb52bba10e7f8efdbaff5b3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 22:35:26 +0200 Subject: [PATCH 3/7] docs(adr): 0048 marks name their instance; switch steps cover orphans --- ...eep-its-simulators-in-xcodes-device-set.md | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index 69cb563c8..b696e1252 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -9,13 +9,15 @@ ADR 0001 puts every Simlock simulator in its own CoreSimulator device set, and every `simctl` call carries `--set ` (`src/drivers/ios/index.ts:1566` at `3de46d1ff`). Being in the set is what proves a device is Simlock's (`src/drivers/ios/index.ts:726-729`). It is an accident boundary, not a security boundary (`src/drivers/AGENTS.md:44-46`). -The set has a cost ADR 0001 did not weigh. `xcodebuild -destination "platform=iOS Simulator,id="` finds a simulator outside Xcode's own set only through the per-user default `DVTSimulatorSetLocation`. This was checked on Xcode 26.4.1, 2026-09-14. Every per-process route fails: a `-DVTSimulatorSetLocation` argument, an environment variable of that name, `HOME` or `CFFIXED_USER_HOME` redirection (with or without `__CFPREFERENCES_AVOID_DAEMON=1`), and `DVT_TOOLCHAIN_USER_DEFAULTS_SUFFIX`. agent-device's XCTest runner passes the argument route (`packages/platform-apple/src/runner/runner-device-set.ts:24-26` at agent-device `v0.21.28`), so it cannot drive a Simlock simulator either. Neither can `react-native run-ios --udid`, Expo `run:ios`, Detox or Maestro. On that Xcode the only route is to build for `generic/platform=iOS Simulator` and install with `simlock simctl install`. +The set has a cost ADR 0001 did not weigh. `xcodebuild -destination "platform=iOS Simulator,id="` finds a simulator outside Xcode's own set only through the per-user default `DVTSimulatorSetLocation`. This was checked on Xcode 26.4.1, 2026-09-14. Every per-process route fails: a `-DVTSimulatorSetLocation` argument, an environment variable of that name, `HOME` or `CFFIXED_USER_HOME` redirection (with or without `__CFPREFERENCES_AVOID_DAEMON=1`), and `DVT_TOOLCHAIN_USER_DEFAULTS_SUFFIX`. agent-device's XCTest runner passes the argument route (`packages/platform-apple/src/runner/runner-device-set.ts:24-26` at agent-device `v0.21.28`), so it cannot drive a Simlock simulator either. Neither can `react-native run-ios --udid`, Expo `run:ios`, Detox or Maestro. On that Xcode the only route for any of them is to build for `generic/platform=iOS Simulator` and install with `simlock simctl install`. -Xcode 27.0 changed this, checked 2026-10-23 with a simulator in a temporary set: `xcodebuild build -destination "platform=iOS Simulator,id="` fails with "Unable to find a device matching the provided destination specifier", and the same command with `-DVTSimulatorSetLocation=` prints `** BUILD SUCCEEDED **`. The argument applies to that one command: `-showdestinations` with it lists only that set's simulators, and no user default is written. agent-device's runner passes the same argument. A tool that lets the caller add `xcodebuild` arguments can then reach Simlock's set (`react-native run-ios --extra-params`). One that does not, such as Expo `run:ios`, still cannot. Simlock names no minimum Xcode version (`website/src/content/docs/docs/introduction/requirements.mdx:15`). +Xcode 27.0 changed this, checked 2026-10-23 with a simulator in a temporary set: `xcodebuild build -destination "platform=iOS Simulator,id="` fails with "Unable to find a device matching the provided destination specifier", and the same command with `-DVTSimulatorSetLocation=` prints `** BUILD SUCCEEDED **`. The argument applies to that one command: `-showdestinations` with it lists only that set's simulators, and no user default is written. agent-device's runner passes the same argument. A tool that lets the caller add `xcodebuild` arguments can then reach Simlock's set. One that runs `xcodebuild` with only its own arguments cannot. Simlock names no minimum Xcode version (`website/src/content/docs/docs/introduction/requirements.mdx:15`). Android has no such cost: `adb`, Gradle and the React Native CLI honour `ANDROID_ADB_SERVER_PORT`, which the grant exports. -What the set still guards against: `xcrun simctl shutdown all`, `erase all`, `delete unavailable`, Xcode's "Delete All Simulators", and a tool that picks `booted`. What protects a user's own simulators whatever the set: cleanup, `nuke` and the warm pool act only on registry devices (`docs/internal/ARCHITECTURE.md:112-113`). A leased device shut down from outside is rebooted under its lease. A missing one ends its lease as `device-missing` (`src/leasing/lease-health-monitor.ts:216-219`). An erased one ends its lease as `provenance-drift` once it runs again (`src/leasing/lease-health-monitor.ts:238-240`). ADR 0034 runs one worker per OS user, so Xcode's set has one Simlock owner per machine. +What the set still guards against: `xcrun simctl shutdown all`, `erase all`, `delete unavailable`, Xcode's "Delete All Simulators", and a tool that picks `booted`. What protects a user's own simulators whatever the set: cleanup, `nuke` and the warm pool act only on registry devices (`docs/internal/ARCHITECTURE.md:112-113`). A leased device shut down from outside is rebooted under its lease. A missing one ends its lease as `device-missing` (`src/leasing/lease-health-monitor.ts:216-219`). An erased one ends its lease as `provenance-drift` once it runs again (`src/leasing/lease-health-monitor.ts:238-240`). + +Xcode's set belongs to an OS user, and more than one Simlock can run as that user: each `SIMLOCK_HOME` is its own instance with its own `instanceId` (`src/core/AGENTS.md`, Known gaps; the slow lane starts one per run under `/tmp/simlock-slow`, `scripts/slow-e2e.sh:22-25`). The durable mark today holds a token, the UDID and a time, and no instance (`src/drivers/ios/index.ts:1449-1462`). ## Decision @@ -30,20 +32,20 @@ Any other value, or `drivers.ios.deviceRoot` set together with `"default"`, turn The per-user `DVTSimulatorSetLocation` default is not a third value, and Simlock never writes it (see Alternatives). -### 2. Under `default`, a device's own mark proves it is Simlock's +### 2. Under `default`, a device's own mark proves it is this instance's -In Xcode's set, being there proves nothing: the user's own simulators are there too. So under `default`, `listManaged` reports only devices whose folder holds Simlock's durable mark, the one `provision` already writes (`src/drivers/ios/index.ts:741`, `1449-1462`). A device without it is never listed, never an orphan, and never reached by `doctor --purge-orphans`. The root marker of ADR 0001 §2 does not apply to Xcode's set, and Simlock never writes into that folder outside a device it created. +In Xcode's set, being there proves nothing: the user's own simulators are there, and so may another Simlock instance's. So the mark `provision` and `reclaim` write (`src/drivers/ios/index.ts:741`, `1062`, `1449-1462`) gains this instance's `instanceId`, under both settings. Under `default`, `listManaged` reports only devices whose durable mark names this instance. Any other device (no mark, a mark with no instance, or another instance's mark) is never listed, never an orphan, and never reached by `doctor --purge-orphans`. The root marker of ADR 0001 §2 does not apply to Xcode's set, and Simlock never writes into that folder outside a device it created. -The same mark keeps a user's simulators apart in the disk report too: a device without the mark counts as the user's, and one with it counts as Simlock's. +The disk report reads the same rule: a device this instance's mark does not name counts as foreign. ### 3. A changed setting refuses iOS until the operator clears the old devices At startup the iOS driver looks where the other setting keeps devices: - under `default`: any device in Simlock's own set; -- under `simlock`: any device in Xcode's set that carries Simlock's durable mark. +- under `simlock`: any device in Xcode's set whose durable mark names this instance. -If it finds one, iOS does not start (`driver.root-rejected`, reason `device-set-changed`). The message tells the operator to set the key back, run `simlock nuke --delete-devices`, then switch again. Simlock does not move or recreate devices: a switch is rare, and documented operator steps cost less than a migration (the same trade as ADR 0036's upgrade step). +If it finds one, iOS does not start (`driver.root-rejected`, reason `device-set-changed`). The message names the devices' UDIDs and the steps: set the key back, run `simlock nuke --delete-devices` and `simlock doctor --purge-orphans`, then switch again. A device those leave behind (nuke deletes only registry devices it could shut down, `src/core/nuke-service.ts:95-150`) is deleted by hand, and the message gives the command: `xcrun simctl --set delete ` under `default`, `xcrun simctl delete ` under `simlock`. Simlock does not move or recreate devices: a switch is rare, and documented operator steps cost less than a migration (the same trade as ADR 0036's upgrade step). ### 4. Under `default`, nothing names a set @@ -51,7 +53,7 @@ The grant carries no `SIMLOCK_IOS_DEVICE_SET`. Neither does the screen-capture p ### 5. `simlock simctl` keeps its refusals -Under `default` the wrapper adds no `--set`, and it still refuses `create`, `erase`, `delete` and a caller's `--set` or `--profiles`. An agent told to use the wrapper stays guarded. An agent that runs `xcrun simctl` directly is not, and the docs say what `shutdown all` and `erase all` then do to a leased device. +Under `default` the wrapper adds no `--set`, and it still refuses `create`, `erase`, `delete`, `shutdown all`, `runtime delete` and a caller's `--set` or `--profiles`. An agent told to use the wrapper cannot change a device's lifecycle. It can still list, boot, install on or shut down any simulator in Xcode's set by UDID, the user's own included, as it can reach another agent's device today (`src/drivers/AGENTS.md`, Known gaps). Scoping the wrapper to the lease's device is #948. An agent that runs `xcrun simctl` directly is not guarded at all, and the docs say what `shutdown all` does to a leased device. ### 6. When to flip the default @@ -79,14 +81,15 @@ flowchart LR - `src/drivers/ios/AGENTS.md`'s "Never `simctl` without `--set`" holds under `simlock` only. - Under `default`, a worker's simulators show in Xcode's destination picker and in plain `xcrun simctl list`. -- A careless `xcrun simctl shutdown all` reboots leased devices under their leases. A careless `erase all` ends their leases as `provenance-drift`, and the holder loses installed apps. +- A careless `xcrun simctl shutdown all` stops leased devices; Simlock reboots each under its lease, and the holder loses running apps. A leased device erased while stopped ends its lease as `provenance-drift` once it runs again. +- Simlock's own background work (cleanup, the warm pool, `nuke`, `doctor --purge-orphans`) never acts on a simulator this instance's mark does not name. - `driver.root-rejected` gains two reasons, `invalid-device-set` and `device-set-changed`; `docs/internal/EVENTS.md` and the website's cleanup page list them. -- The website gains a "Build and run your app" page. Under `simlock`: the `-DVTSimulatorSetLocation="$SIMLOCK_IOS_DEVICE_SET"` argument on Xcode 27 or later, and the generic build and install on older Xcode or with a tool that takes no `xcodebuild` arguments. Under `default`: direct targeting by UDID. On Android: the adb serial. +- The website gains a "Build and run your app" page. Under `simlock`: the `-DVTSimulatorSetLocation="$SIMLOCK_IOS_DEVICE_SET"` argument on Xcode 27 or later, and the generic build and install on older Xcode or with a tool that takes no extra `xcodebuild` arguments. Under `default`: direct targeting by UDID. On Android: the adb serial. ## Alternatives considered - **Write `DVTSimulatorSetLocation` for the user.** Rejected by the maintainer on 2026-09-14 and again on 2026-10-23. It applies to every `xcodebuild` run of that user, and it hides the user's own simulators while it is set. -- **No switch: Xcode 27's per-command argument is enough.** Rejected: it does not help older Xcode, or a tool that takes no `xcodebuild` arguments (Expo `run:ios`). The docs give the argument as the first route under `simlock`. +- **No switch: Xcode 27's per-command argument is enough.** Rejected: it does not help older Xcode, or a tool that runs `xcodebuild` with only its own arguments. The docs give the argument as the first route under `simlock`. - **Make `default` the default now.** Rejected for now: recovery after an external erase has no slow-lane test yet. §6 says when to flip it. - **Migrate devices when the setting changes.** Rejected: rare, and a lot of code for one switch. §3's refusal and steps are enough. - **Under `default`, make `simlock simctl` a plain passthrough.** Rejected: agents told to use it would lose their guard for nothing. From b968851fa5f3f5263a49e49f9c6920c7632d2a3a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:09:13 +0200 Subject: [PATCH 4/7] docs(adr): accept 0048 (not yet implemented) --- ...n-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md | 2 +- docs/internal/adr/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index b696e1252..6d6e0a14b 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -1,6 +1,6 @@ # 0048. An iOS worker may keep its simulators in Xcode's device set -- **Status:** Proposed +- **Status:** Accepted — not yet implemented - **Date:** 2026-10-23 - **Issue:** [#962](https://github.com/callstackincubator/simlock/issues/962), from request [#946](https://github.com/callstackincubator/simlock/issues/946) - **Narrows:** ADR 0001 §2, §3, §5 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index a57b76903..dd8218511 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -84,4 +84,4 @@ the status is stale. | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | | [0044](0044-the-console-reads-what-its-page-shows-and-events-mark-it-stale.md) | The console reads what its page shows, and events mark it stale | Accepted — not yet implemented; supersedes 0013 §3 | | [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | -| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Proposed; narrows 0001 §2, §3, §5 and §7 for iOS under `deviceSet: "default"` | +| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §2, §3, §5 and §7 for iOS under `deviceSet: "default"` | From 246b1796f7eb8efb6dc90bcaf3ff0481c2c036be Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 20:19:25 +0200 Subject: [PATCH 5/7] docs(adr): 0048 amends safety rules, names itself in ADR 0001, settles custom deviceRoot migration (#962) --- .../adr/0001-simlock-owned-device-roots.md | 2 +- ...y-keep-its-simulators-in-xcodes-device-set.md | 4 ++-- docs/internal/agent-rules/safety.md | 16 +++++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/internal/adr/0001-simlock-owned-device-roots.md b/docs/internal/adr/0001-simlock-owned-device-roots.md index df4b3470b..f33a1c1dd 100644 --- a/docs/internal/adr/0001-simlock-owned-device-roots.md +++ b/docs/internal/adr/0001-simlock-owned-device-roots.md @@ -2,7 +2,7 @@ - **Status:** Accepted. Root membership as the only proof of ownership, and the Android driver's one adb server, are narrowed by [ADR - 0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). + 0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). §2, §3, §5 and §7 are narrowed by [ADR 0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) for iOS simulators under `drivers.ios.deviceSet: "default"`. - **Date:** 2026-09-02 - **Issue:** [#73](https://github.com/callstackincubator/simlock/issues/73), part of [#70](https://github.com/callstackincubator/simlock/issues/70) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index 6d6e0a14b..ffe8ec5d7 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -42,10 +42,10 @@ The disk report reads the same rule: a device this instance's mark does not name At startup the iOS driver looks where the other setting keeps devices: -- under `default`: any device in Simlock's own set; +- under `default`: any device in Simlock's own default set, `${SIMLOCK_HOME}/devices/ios`. A custom `drivers.ios.deviceRoot` is not read: the key is already refused together with `default` (§1), and no registry check looks for it; - under `simlock`: any device in Xcode's set whose durable mark names this instance. -If it finds one, iOS does not start (`driver.root-rejected`, reason `device-set-changed`). The message names the devices' UDIDs and the steps: set the key back, run `simlock nuke --delete-devices` and `simlock doctor --purge-orphans`, then switch again. A device those leave behind (nuke deletes only registry devices it could shut down, `src/core/nuke-service.ts:95-150`) is deleted by hand, and the message gives the command: `xcrun simctl --set delete ` under `default`, `xcrun simctl delete ` under `simlock`. Simlock does not move or recreate devices: a switch is rare, and documented operator steps cost less than a migration (the same trade as ADR 0036's upgrade step). +If it finds one, iOS does not start (`driver.root-rejected`, reason `device-set-changed`). The message names the devices' UDIDs and the steps: set the key back, run `simlock nuke --delete-devices` and `simlock doctor --purge-orphans`, then switch again. An operator who set `drivers.ios.deviceRoot` runs `simlock nuke --delete-devices` and `simlock doctor --purge-orphans` with it still set, and removes it only when switching to `default`. A device those leave behind (nuke deletes only registry devices it could shut down, `src/core/nuke-service.ts:95-150`) is deleted by hand, and the message gives the command: `xcrun simctl --set delete ` under `default`, `xcrun simctl delete ` under `simlock`. Simlock does not move or recreate devices: a switch is rare, and documented operator steps cost less than a migration (the same trade as ADR 0036's upgrade step). ### 4. Under `default`, nothing names a set diff --git a/docs/internal/agent-rules/safety.md b/docs/internal/agent-rules/safety.md index 807c65c87..7e76a7aa0 100644 --- a/docs/internal/agent-rules/safety.md +++ b/docs/internal/agent-rules/safety.md @@ -47,6 +47,10 @@ never enforce them only inside an individual rule or driver. has none, so an orphan proposal would bypass the entire safety net rather than be checked by it. Keep marker validation off every unattended destruction path. + Under iOS `drivers.ios.deviceSet: "default"` there is no Simlock root: the + second proof is a device's durable mark naming this instance, and only such + a device is an orphan ([ADR 0048](../adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) + §2). The same narrowness applies to it. 2. **Never touch a leased device.** No cleanup rule, reclaim, or reconcile action may target a device in `leased` state. The reaper filters this centrally; rules must not rely on their own checks. There is exactly one @@ -86,19 +90,25 @@ never enforce them only inside an individual rule or driver. registry entries whose device vanished are marked, not silently recreated or re-deleted. "Reality" means the contents of Simlock's own device roots, scoped by `simctl --set` and `ANDROID_AVD_HOME` — never the machine's - default device locations. For a physical device, reality is the platform + default device locations. (Under iOS `deviceSet: "default"`, reality is Xcode's set read through + devices whose durable mark names this instance, ADR 0048 §2.) For a physical device, reality is the platform tool's list of USB devices, read only for enrolled IDs. 8. **Ownership is proven, never inferred.** A device is Simlock's because it lives inside a validly-marked Simlock root, not because of what it is called. `Driver.listManaged()` must answer from root membership; the `simlock-` / `simlock_` naming is a cosmetic label with no authority behind it. Never treat a name, a prefix, or a serial-to-name attribution as - evidence of ownership — a user can create a device with any name. A + evidence of ownership — a user can create a device with any name. Under iOS + `deviceSet: "default"` the proof is the device's durable mark naming this + instance, not root membership (ADR 0048 §2). A physical device is Simlock's because an admin enrolled it; `listPresentPhysical()` answers only for enrolled IDs. 9. **Root validation fails closed.** If a device root is missing its marker, carries another instance's marker, is a symlink, or has the wrong owner or - permissions, that platform's driver does not start and Simlock reports why. + permissions, that platform's driver does not start and Simlock reports why. Under iOS + `deviceSet: "default"` there is no root to validate; the driver instead + refuses to start while devices remain in the other setting's location (ADR + 0048 §3). Never fall back to the default device location, and never adopt or mark a root Simlock did not create empty itself. The same applies to Simlock's adb server port: if it is occupied, the Android driver fails rather than From b8ffd9758c8bdc43bc75c76fdc008df20bfcb6aa Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 20:24:02 +0200 Subject: [PATCH 6/7] =?UTF-8?q?docs(adr):=200048=20narrows=20ADR=200001=20?= =?UTF-8?q?=C2=A71=20and=20=C2=A76=20as=20well=20(#962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/adr/0001-simlock-owned-device-roots.md | 2 +- ...ios-worker-may-keep-its-simulators-in-xcodes-device-set.md | 2 +- docs/internal/adr/README.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/internal/adr/0001-simlock-owned-device-roots.md b/docs/internal/adr/0001-simlock-owned-device-roots.md index f33a1c1dd..49319e079 100644 --- a/docs/internal/adr/0001-simlock-owned-device-roots.md +++ b/docs/internal/adr/0001-simlock-owned-device-roots.md @@ -2,7 +2,7 @@ - **Status:** Accepted. Root membership as the only proof of ownership, and the Android driver's one adb server, are narrowed by [ADR - 0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). §2, §3, §5 and §7 are narrowed by [ADR 0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) for iOS simulators under `drivers.ios.deviceSet: "default"`. + 0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). §1 (every driver owns a root), §2, §3, §5, §6 and §7 are narrowed by [ADR 0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) for iOS simulators under `drivers.ios.deviceSet: "default"`. - **Date:** 2026-09-02 - **Issue:** [#73](https://github.com/callstackincubator/simlock/issues/73), part of [#70](https://github.com/callstackincubator/simlock/issues/70) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index ffe8ec5d7..966e7ec28 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -3,7 +3,7 @@ - **Status:** Accepted — not yet implemented - **Date:** 2026-10-23 - **Issue:** [#962](https://github.com/callstackincubator/simlock/issues/962), from request [#946](https://github.com/callstackincubator/simlock/issues/946) -- **Narrows:** ADR 0001 §2, §3, §5 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged. +- **Narrows:** ADR 0001 §1, §2, §3, §5, §6 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged. ## Context diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index c28915ea7..e54066339 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -39,7 +39,7 @@ the status is stale. | ADR | Title | Status | |---|---|---| -| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`); §2, §3, §5 and §7 narrowed by 0048 for iOS under `deviceSet: "default"` | +| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`); §1, §2, §3, §5, §6 and §7 narrowed by 0048 for iOS under `deviceSet: "default"` | | [0002](0002-opt-in-slim-ios-simulators.md) | Opt-in slim iOS simulators | Accepted | | [0003](0003-one-typed-daemon-contract-behind-every-frontend.md) | One typed daemon contract behind every frontend | Accepted | | [0004](0004-ttl-first-leases-on-every-transport.md) | TTL-first leases on every transport | Accepted — partly implemented: §2 (the CLI exits when its socket drops instead of keeping its lease) | @@ -84,6 +84,6 @@ the status is stale. | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part; §4 narrowed by 0046 | | [0044](0044-the-console-reads-what-its-page-shows-and-events-mark-it-stale.md) | The console reads what its page shows, and events mark it stale | Accepted — not yet implemented; supersedes 0013 §3 | | [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | -| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §2, §3, §5 and §7 for iOS under `deviceSet: "default"` | +| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` | | [0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md) | A standalone worker serves agent-device by default and hands local agents a private profile | Accepted — not yet implemented; narrows 0023 §1, §3–4, 0030 §2, 0039 and 0043 §4 | | [0047](0047-device-exec-fills-in-the-leases-device.md) | `device.exec` fills in the lease's device, and refuses another | Accepted — not yet implemented; extends 0005 §19a; narrows 0003 §11 for one MCP tool | From 902059a40b9e6cda14d6b09c6c13eeea255ed00b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 20:46:36 +0200 Subject: [PATCH 7/7] docs(adr): list 0048 after 0047 in the index (#962) --- docs/internal/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index e54066339..8405702bc 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -84,6 +84,6 @@ the status is stale. | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part; §4 narrowed by 0046 | | [0044](0044-the-console-reads-what-its-page-shows-and-events-mark-it-stale.md) | The console reads what its page shows, and events mark it stale | Accepted — not yet implemented; supersedes 0013 §3 | | [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | -| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` | | [0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md) | A standalone worker serves agent-device by default and hands local agents a private profile | Accepted — not yet implemented; narrows 0023 §1, §3–4, 0030 §2, 0039 and 0043 §4 | | [0047](0047-device-exec-fills-in-the-leases-device.md) | `device.exec` fills in the lease's device, and refuses another | Accepted — not yet implemented; extends 0005 §19a; narrows 0003 §11 for one MCP tool | +| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` |