Problem
Simlock never installs platform components. If an agent asks for an iPhone 14 Pro on iOS 18.0 and the iOS 18.0 runtime is not installed, the lease fails — even with --allow-download, because the iOS driver ignores the flag entirely (src/drivers/ios/index.ts resolveSpec takes _options). Android already installs missing system images via sdkmanager --install when allowDownload is set, but there is no config-level policy, no license handling, no events/progress, and the device-profile source is hardwired to avdmanager.
This issue makes simlock able to install missing components to fulfil agent requests, gated by configuration.
Research findings (verified 2026-09, Xcode 27.0 beta / current cmdline-tools)
iOS components
- Device type (the "iPhone 14 Pro" half): definitions ship inside Xcode and are essentially never removed (Xcode 27 still lists iPhone SE 1st gen, 2016). Not installable — a model unknown to the installed Xcode requires a newer Xcode (Apple ID + admin), which is out of scope. This is a hard error with a human message: no such device; upgrading Xcode may add it.
- Simulator runtime (the "iOS 18.0" half): downloadable, ~7 GB. CLI:
xcodebuild -downloadPlatform iOS (latest for that Xcode) or -buildVersion <ver> for a specific released version (Xcode 16.1+, reaches back to iOS 16.0 — that floor is a hard limit). No Apple ID, no sudo.
- Pairing is the real constraint: each runtime only supports certain device types (iOS 26+ runtimes dropped iPhone XS/XR, mirroring real-device support). Two offline data sources make resolution fully deterministic:
simctl list devicetypes -j → minRuntimeVersion / maxRuntimeVersion per device type (encoded 0xAABBCC → AA.BB.CC), available without the runtime installed;
simctl list runtimes -j → supportedDeviceTypes per installed runtime (authoritative once installed).
Android components
- System image:
sdkmanager --install "system-images;android-X;google_apis;<abi>" — already implemented in the driver.
- Device profile (the "Pixel 7" half): a hardware descriptor, no pairing constraint with system images. Built-ins ship in
avdmanager's sdklib resources; Android Studio writes user-defined profiles to ~/.android/devices.xml. No official Google profile store exists beyond the tools.
- Licenses:
sdkmanager refuses installs until Google licenses are accepted. Legal consent → its own explicit config opt-in, never implied by the download policy.
Design
1. Config-level download policy
New downloads config section (src/core/config.ts):
never — installs forbidden even when a request passes --allow-download (locked-down machines/CI). The failure message must say downloads are disabled by config, not just "runtime missing".
on-request (default) — exactly today's contract: install only when the request carries --allow-download / MCP allow_download.
always — the daemon may install missing components for any explicit lease request without the per-request flag.
Effective allowDownload = policy === "always" ? true : policy === "never" ? false : request.allowDownload. Warm-pool provisioning and startup convergence never download — only explicit lease requests reach resolveSpec with download permission.
Amend docs/agent-rules/safety.md rule 4: explicit config opt-in (downloads.policy: "always") counts as the required explicit consent alongside --allow-download.
2. iOS driver: pairing-aware resolution + runtime download
resolveSpec (src/drivers/ios/index.ts) becomes:
- Model unknown to
simctl list devicetypes → UnknownModelError, translated for humans as "no such device — a newer Xcode may add it".
- OS version requested → validate against the device type's
[minRuntimeVersion, maxRuntimeVersion] before any download. Out of range → hard error naming the supported range (e.g. "iPhone Xs supports iOS 12.0–18.x"). Never attempt a download that cannot work.
- Version in range, runtime missing, download allowed → below iOS 16.0 is a hard error ("runtime too old to download automatically"); otherwise run
xcodebuild -downloadPlatform iOS -buildVersion <ver> (generous configurable timeout), then re-scan the catalog and proceed.
- No OS version requested → default to the newest installed runtime that (a) falls in the device type's range and (b) lists the model in its
supportedDeviceTypes — not the newest installed runtime overall. If none installed and download allowed: unbounded max → plain -downloadPlatform iOS (latest); bounded max → attempt the major from maxRuntimeVersion, and on failure error clearly telling the caller to pass an exact version. (Parsing Apple's downloadables catalog for exact versions goes to docs/IDEAS.md, not v1.)
- "Runtime missing" errors always name the concrete fix: the downloadable versions and whether
--allow-download / downloads.policy would help.
Dedupe concurrent downloads of the same runtime behind a per-component in-driver promise lock (same pattern as the Android driver's #locks). The download runs inside resolveSpec, which LeaseAcquisitionCoordinator#resolveAndDrive already calls per-request outside the serialized decision gate and outside the FIFO head — verify a slow download stalls only its own request, and document the behavior.
3. Android: extensible read-only profile sources + license handling
Simlock loads device profiles from multiple places; it never registers/writes profiles anywhere (no writes to ~/.android/devices.xml — that file belongs to Android Studio and stays strictly read-only, per the spirit of safety rule 1).
New port, hidden behind an abstraction with an ordered source list (same registration pattern as CapacityStrategy):
DeviceProfileSource
listModels(): profile names this source can resolve
resolve(model): BuiltinProfile { avdmanagerId } // create with `avdmanager -d <id>`
| PropertiesProfile { hardwareProperties } // applied to the simlock AVD's config.ini after create
- Built-in source (default): resolves against
avdmanager list device — current behavior, refactored behind the port.
- User source (default): read-only parse of
~/.android/devices.xml; resolved profiles are applied as config.ini hardware properties on the simlock-created AVD (the driver already rewrites AVD config state for snapshot hashing — established territory).
- Future community/network source: an explicit extension point (ordered registration list, first match wins, dedupe by name) — not implemented in v1, just structurally trivial to add.
License handling: when an install fails on unaccepted licenses and downloads.acceptAndroidLicenses is true, accept via sdkmanager --licenses (piped confirmation) and retry once; when false, fail with a message naming the config key and the manual command.
4. Observability + bookkeeping
- New events in
docs/EVENTS.md (rules in docs/agent-rules/events.md — subject.past-tense-fact, post-commit): component.install-started, component.installed, component.install-failed — payload: platform, component id (runtime version / sdkmanager package), initiating request, duration/error. Drivers surface installs through a diagnostic callback (the Android driver's onDiagnostic pattern); the daemon wires that to the event bus.
- Download progress flows to the waiting requester through the existing lease progress stream.
- Disk preflight: before an install, check free disk against a conservative per-component size estimate (~8 GB iOS runtime, ~2 GB Android image); fail fast with a clear message instead of filling the disk (interacts with
diskPressure.freeBytesThreshold).
- No uninstall in v1: components simlock installed are recorded (registry/log) so a future disk-pressure cleanup rule may target them under safety rule 1, but nothing deletes runtimes/images now.
- Document in
docs/known-pitfalls.md: per-request download blocking (only its own request), and the bounded-max default-version edge case.
Out of scope
- Installing/upgrading Xcode (device types) — clear error only.
- Uninstalling components.
- Community/network profile source implementations (extension point only).
- Parsing Apple's downloadables index for the exact-version catalog (→ IDEAS.md).
Work breakdown
- Config + policy plumbing —
downloads section, effective-allowDownload computation in the daemon request path, safety.md amendment, tests.
- iOS download path — pairing-aware
resolveSpec (steps 1–5 above), xcodebuild install, per-component lock, error taxonomy, tests (scripted ProcessRunner fixtures).
- Android profile sources + licenses —
DeviceProfileSource port, built-in + user sources, config.ini property application, license opt-in flow, tests.
- Events, progress, preflight, docs —
component.install-* wiring, progress stream, disk preflight, EVENTS.md / ARCHITECTURE.md / known-pitfalls.md updates.
Acceptance criteria
downloads.policy gates installs exactly as specified for both platforms; never beats --allow-download; default preserves today's behavior byte-for-byte.
- iOS: iPhone XS request with only iOS 26+ runtimes installed resolves to a downloadable iOS 18.x (or a clear error without download permission); iPhone 7 request errors as non-downloadable; unknown model errors point at Xcode.
- Android: a profile defined only in
~/.android/devices.xml provisions successfully without simlock writing to that file; system-image install honors the license opt-in.
- All install paths emit
component.install-*; no install can be triggered by warm-pool or startup paths.
pnpm run check passes.
Problem
Simlock never installs platform components. If an agent asks for an iPhone 14 Pro on iOS 18.0 and the iOS 18.0 runtime is not installed, the lease fails — even with
--allow-download, because the iOS driver ignores the flag entirely (src/drivers/ios/index.tsresolveSpectakes_options). Android already installs missing system images viasdkmanager --installwhenallowDownloadis set, but there is no config-level policy, no license handling, no events/progress, and the device-profile source is hardwired toavdmanager.This issue makes simlock able to install missing components to fulfil agent requests, gated by configuration.
Research findings (verified 2026-09, Xcode 27.0 beta / current cmdline-tools)
iOS components
xcodebuild -downloadPlatform iOS(latest for that Xcode) or-buildVersion <ver>for a specific released version (Xcode 16.1+, reaches back to iOS 16.0 — that floor is a hard limit). No Apple ID, no sudo.simctl list devicetypes -j→minRuntimeVersion/maxRuntimeVersionper device type (encoded0xAABBCC→AA.BB.CC), available without the runtime installed;simctl list runtimes -j→supportedDeviceTypesper installed runtime (authoritative once installed).Android components
sdkmanager --install "system-images;android-X;google_apis;<abi>"— already implemented in the driver.avdmanager's sdklib resources; Android Studio writes user-defined profiles to~/.android/devices.xml. No official Google profile store exists beyond the tools.sdkmanagerrefuses installs until Google licenses are accepted. Legal consent → its own explicit config opt-in, never implied by the download policy.Design
1. Config-level download policy
New
downloadsconfig section (src/core/config.ts):{ "downloads": { "policy": "on-request", // "never" | "on-request" | "always" "acceptAndroidLicenses": false, // explicit legal consent, independent of policy "timeoutMs": 1200000 // per-install timeout (downloads run minutes) } }never— installs forbidden even when a request passes--allow-download(locked-down machines/CI). The failure message must say downloads are disabled by config, not just "runtime missing".on-request(default) — exactly today's contract: install only when the request carries--allow-download/ MCPallow_download.always— the daemon may install missing components for any explicit lease request without the per-request flag.Effective
allowDownload=policy === "always" ? true : policy === "never" ? false : request.allowDownload. Warm-pool provisioning and startup convergence never download — only explicit lease requests reachresolveSpecwith download permission.Amend
docs/agent-rules/safety.mdrule 4: explicit config opt-in (downloads.policy: "always") counts as the required explicit consent alongside--allow-download.2. iOS driver: pairing-aware resolution + runtime download
resolveSpec(src/drivers/ios/index.ts) becomes:simctl list devicetypes→UnknownModelError, translated for humans as "no such device — a newer Xcode may add it".[minRuntimeVersion, maxRuntimeVersion]before any download. Out of range → hard error naming the supported range (e.g. "iPhone Xs supports iOS 12.0–18.x"). Never attempt a download that cannot work.xcodebuild -downloadPlatform iOS -buildVersion <ver>(generous configurable timeout), then re-scan the catalog and proceed.supportedDeviceTypes— not the newest installed runtime overall. If none installed and download allowed: unbounded max → plain-downloadPlatform iOS(latest); bounded max → attempt the major frommaxRuntimeVersion, and on failure error clearly telling the caller to pass an exact version. (Parsing Apple's downloadables catalog for exact versions goes todocs/IDEAS.md, not v1.)--allow-download/downloads.policywould help.Dedupe concurrent downloads of the same runtime behind a per-component in-driver promise lock (same pattern as the Android driver's
#locks). The download runs insideresolveSpec, whichLeaseAcquisitionCoordinator#resolveAndDrivealready calls per-request outside the serialized decision gate and outside the FIFO head — verify a slow download stalls only its own request, and document the behavior.3. Android: extensible read-only profile sources + license handling
Simlock loads device profiles from multiple places; it never registers/writes profiles anywhere (no writes to
~/.android/devices.xml— that file belongs to Android Studio and stays strictly read-only, per the spirit of safety rule 1).New port, hidden behind an abstraction with an ordered source list (same registration pattern as
CapacityStrategy):avdmanager list device— current behavior, refactored behind the port.~/.android/devices.xml; resolved profiles are applied asconfig.inihardware properties on the simlock-created AVD (the driver already rewrites AVD config state for snapshot hashing — established territory).License handling: when an install fails on unaccepted licenses and
downloads.acceptAndroidLicensesistrue, accept viasdkmanager --licenses(piped confirmation) and retry once; whenfalse, fail with a message naming the config key and the manual command.4. Observability + bookkeeping
docs/EVENTS.md(rules indocs/agent-rules/events.md—subject.past-tense-fact, post-commit):component.install-started,component.installed,component.install-failed— payload: platform, component id (runtime version / sdkmanager package), initiating request, duration/error. Drivers surface installs through a diagnostic callback (the Android driver'sonDiagnosticpattern); the daemon wires that to the event bus.diskPressure.freeBytesThreshold).docs/known-pitfalls.md: per-request download blocking (only its own request), and the bounded-max default-version edge case.Out of scope
Work breakdown
downloadssection, effective-allowDownload computation in the daemon request path,safety.mdamendment, tests.resolveSpec(steps 1–5 above),xcodebuildinstall, per-component lock, error taxonomy, tests (scriptedProcessRunnerfixtures).DeviceProfileSourceport, built-in + user sources,config.iniproperty application, license opt-in flow, tests.component.install-*wiring, progress stream, disk preflight, EVENTS.md / ARCHITECTURE.md / known-pitfalls.md updates.Acceptance criteria
downloads.policygates installs exactly as specified for both platforms;neverbeats--allow-download; default preserves today's behavior byte-for-byte.~/.android/devices.xmlprovisions successfully without simlock writing to that file; system-image install honors the license opt-in.component.install-*; no install can be triggered by warm-pool or startup paths.pnpm run checkpasses.