Skip to content

Install missing platform components on demand (iOS runtimes, Android system images, extensible profile sources) #67

Description

@V3RON

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 -jminRuntimeVersion / maxRuntimeVersion per device type (encoded 0xAABBCCAA.BB.CC), available without the runtime installed;
    • simctl list runtimes -jsupportedDeviceTypes 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):

{
  "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 / 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:

  1. Model unknown to simctl list devicetypesUnknownModelError, translated for humans as "no such device — a newer Xcode may add it".
  2. 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.
  3. 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.
  4. 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.)
  5. "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.mdsubject.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

  1. Config + policy plumbingdownloads section, effective-allowDownload computation in the daemon request path, safety.md amendment, tests.
  2. iOS download path — pairing-aware resolveSpec (steps 1–5 above), xcodebuild install, per-component lock, error taxonomy, tests (scripted ProcessRunner fixtures).
  3. Android profile sources + licensesDeviceProfileSource port, built-in + user sources, config.ini property application, license opt-in flow, tests.
  4. Events, progress, preflight, docscomponent.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions