Skip to content

feat: on-demand component installation for iOS runtimes and Android system images - #69

Open
V3RON wants to merge 6 commits into
mainfrom
feat/component-installation
Open

feat: on-demand component installation for iOS runtimes and Android system images#69
V3RON wants to merge 6 commits into
mainfrom
feat/component-installation

Conversation

@V3RON

@V3RON V3RON commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #67.

What is this?

This PR lets simlock install missing platform components instead of failing the lease. Until now, an agent asking for "iPhone 14 Pro on iOS 18.0" got a hard error unless the iOS 18.0 runtime was already installed — the --allow-download flag existed but the iOS driver ignored it entirely, and Android could install system images but with no operator-level control, no license handling, and no visibility. Now a missing iOS simulator runtime or Android system image can be downloaded on demand, governed by a new config policy, and Android device profiles can be loaded from more than one place.

How does it work?

A new downloads config section decides who may trigger installs: policy: "never" blocks them even when a request passes --allow-download, "on-request" (the default, preserving today's contract) requires the per-request flag, and "always" lets the daemon install for any explicit lease request. The policy resolves once, at the daemon's lease-request entry point; warm-pool provisioning and startup convergence can never trigger a download.

On iOS, resolveSpec is now pairing-aware: it decodes each device type's supported OS range and each installed runtime's supportedDeviceTypes from simctl, so a default-OS request picks the newest runtime that actually pairs with the model (iOS 26 dropped iPhone XS — an XS request now correctly lands on an iOS 18 runtime). A missing-but-downloadable runtime is fetched via xcodebuild -downloadPlatform iOS, with a hard floor at iOS 16.0 (Xcode's own limit), typed errors that say exactly what would fix the request, and concurrent requests for the same runtime deduplicated behind one download.

On Android, device-profile resolution moved behind an ordered, read-only DeviceProfileSource registry: the built-in avdmanager list first, then Android Studio's ~/.android/devices.xml (parsed read-only, never written), with a structural slot for future community/network sources. Profiles that aren't built-in apply as hardware properties on the simlock-created AVD's config.ini before the snapshot baseline is captured. System-image installs honor a separate acceptAndroidLicenses opt-in — license consent is never implied by download permission.

Every install emits component.install-started/installed/install-failed events (bridged from driver diagnostics at the daemon layer, never from drivers directly), writes a durable log line, and is preceded by a free-disk preflight against the volume the component actually lands on. Disk-full and license failures map to their own daemon error codes and the actionable CLI exit code, not a generic internal error.

Why is this useful?

  • Agents can request any device/OS combination the machine could support and simlock resolves the gap itself, instead of every consumer re-implementing "check, download, retry" around it.
  • Operators stay in control: locked-down CI can pin policy: "never", workstations can opt into "always", and Google's SDK licenses are only ever accepted by explicit configuration.
  • Failures are actionable by machines: out-of-range versions, too-old runtimes, unknown models, full disks, and unaccepted licenses each get a distinct, typed error that names the fix, rather than a raw simctl/sdkmanager stderr dump minutes into a lease.
  • Multi-gigabyte downloads are safe by construction: deduplicated per component, disk-checked before they start, blocking only the request that triggered them, and fully attributable through events and durable logs.

Introduces `config.downloads.policy` (`"never" | "on-request" | "always"`,
default `"on-request"`) alongside `acceptAndroidLicenses` and `timeoutMs`,
plus the pure `effectiveAllowDownload(policy, requested)` reducer that
combines the policy with a lease request's own `--allow-download` /
`allow_download` flag.

The daemon resolves this once in `#requestLease` before calling
`leases.request`: `"never"` overrides an explicit `true` back to forbidden
(and the resulting RuntimeMissingError names `downloads.policy` in its
message), `"always"` grants permission without the caller having to ask,
and `"on-request"` defers to the request's flag exactly as before. Warm-pool
provisioning and startup convergence never resolve a new spec, so they
remain unaffected by policy under any setting.

Updates docs/agent-rules/safety.md (rule 4) and docs/ARCHITECTURE.md
("Device requests") to describe the policy resolution point and the
warm-pool/startup no-download guarantee, and adds the required `downloads`
field to every hand-built `Config` test fixture.
…-gated installs (#67)

Refactors avdmanager's built-in device-profile resolution behind a
DeviceProfileSource port (ordered list, first match wins) and adds a
second source that does a read-only, dependency-free parse of Android
Studio's ~/.android/devices.xml, mapping its hardware fields onto the
config.ini properties a simlock-created AVD needs. A devices.xml-only
profile is applied to config.ini right after `avdmanager create avd`,
before the driver's snapshot/config-hash baseline is captured.

Also wires downloads.acceptAndroidLicenses through to the driver: an
unaccepted-license install failure is detected from sdkmanager's own
output and either fails naming the config key, or accepts licenses via
piped stdin confirmation and retries the install once. Replaces the
hardcoded SDK_DOWNLOAD_TIMEOUT_MS with a downloadTimeoutMs option wired
from downloads.timeoutMs in the daemon, mirroring the iOS driver.
…l log (#67)

Adds component.install-started / component.installed / component.install-failed
to the event bus and emits them for both drivers' install paths. Drivers never
depend on the bus directly (architecture rule 5): each driver reports the fact
through a new/extended onDiagnostic callback (mirroring the Android driver's
existing pattern; iOS gains the same option), and src/daemon/main.ts bridges
the diagnostic to the bus at driver construction time -- hence the
"driver-diagnostics" emitter. install-failed carries a stable error summary
matching device.purge-failed's own format; the Android license-retry path
emits exactly one install-failed regardless of which branch throws.

Adds a disk-space preflight (assertDiskSpace / InsufficientDiskSpaceError in
core/driver.ts, shared by both drivers) before either install starts: ~8 GiB
for an iOS runtime, ~2 GiB for an Android system image, checked via
Filesystem#diskFree. A preflight failure fires no diagnostic and makes no
xcodebuild/sdkmanager install call.

startDaemon now subscribes logger.child("components") to component.installed
so an installed component stays attributable in daemon.log after the event
ring buffer resets on restart -- no registry entry or uninstall, per the
issue's stated scope.

Investigated threading download progress through to the waiting requester's
lease-progress stream (item 4): doing so needs a new LeaseProgress stage and
a Driver.resolveSpec progress callback both drivers would implement -- real
protocol machinery, not a small addition -- so it was documented as a gap in
docs/known-pitfalls.md and docs/IDEAS.md instead of built.

Updates docs/EVENTS.md (new Components section), docs/ARCHITECTURE.md,
docs/CLI.md, and docs/IDEAS.md (adds the deferred Apple-downloadables-index
idea stage 2's known-pitfalls entry pointed at, plus the progress-push idea).
Ten adversarially-verified review findings, fixed:

1. Extract a shared #mergeConfigIniLines helper for Android's config.ini
   read-modify-write (hardware properties + durable mark write), catching
   only missing-file errors and rethrowing everything else instead of
   silently clobbering config.ini on a real read failure.
2. Dedupe concurrent Android system-image installs behind a per-package
   in-flight promise map, mirroring the iOS driver's #downloadLocks.
3. Map InsufficientDiskSpaceError and the new platform-agnostic
   LicenseNotAcceptedError (AndroidLicenseNotAcceptedError now extends it,
   relocated next to InsufficientDiskSpaceError in core/driver.ts) to their
   own daemon error codes and CLI exit code 12; documented in docs/CLI.md.
4. iOS resolveSpec now also checks that an installed runtime's
   supportedDeviceTypeIds actually pairs with the requested model, not just
   that the version is in range.
5. RuntimeMissingError gained a `downloadable` flag (default true); the
   out-of-range, unpaired-runtime, and download-floor errors set it false so
   the daemon's "downloads are disabled by configuration" suffix never
   attaches to a request no download could have fixed.
6. Disk-space preflight now checks the volume a component actually installs
   to: the Android SDK root, and a new iOS coreSimulatorRoot option (wired
   from daemon/main.ts) instead of the daemon's own working directory.
7. Replaced the iOS 16.0 download-floor DriverCrashError with a typed
   RuntimeMissingError subclass (downloadable: false).
8. The bounded-default iOS runtime download path now rethrows
   InsufficientDiskSpaceError/RuntimeMissingError unchanged instead of
   wrapping them in a DriverCrashError.
9. Deduplicated the four copies of stableError into src/core/stable-error.ts,
   imported by both drivers and both core coordinators.
10. Covered by (1).
Nine adversarially-verified review findings, fixed:

1. The daemon's "downloads are disabled by configuration: downloads.policy
   is \"never\"" suffix now attaches to any downloadable RuntimeMissingError
   under the never policy, regardless of whether the individual request
   asked for a download -- the driver's own message suggests
   --allow-download, which can never help under never, so the suffix is
   the correction every caller needs, not just the ones that asked.
2. iOS's parseCatalog only requires a non-empty devicetypes list now;
   an empty runtimes list (a fresh Xcode with nothing downloaded yet) no
   longer throws before resolveSpec can reach the download-latest path.
   All catalog.runtimes consumers (findInstalledRuntime,
   pairedInstalledRuntime, listCatalog) already tolerated an empty array.
3. #resolveExactRuntime's post-download commit now also checks the
   refreshed runtime's supportedDeviceTypeIds against the requested
   device type, throwing IosRuntimeUnpairedError on a mismatch instead of
   committing a downloaded-but-unpaired runtime -- mirrors the
   already-installed pairing check just above it.
4. device-profile-source.ts's parseDevicesXml now rejects (throws,
   surfaced through the existing malformed-devices.xml diagnostic path)
   any device name or property value containing CR, LF, or NUL, closing a
   config.ini injection route through a crafted devices.xml. The Android
   driver's #mergeConfigIniLines gained its own independent defense-in-depth
   guard rejecting any key/value with an embedded line break.
5. Android's hasUnacceptedLicense regex now also matches "licenses have
   not been accepted." (not just "... not accepted."), the second
   documented sdkmanager phrasing the comment already claimed to cover.
6. component.installed is now a verified fact in both drivers: it fires
   only after a post-install re-scan (iOS: simctl catalog re-load +
   pairing check; Android: system-images tree re-scan) confirms the
   component the request actually needed is present. An installer that
   exits 0 but leaves nothing behind now reports component-install-failed
   and throws (DriverCrashError / IosRuntimeUnpairedError) instead of
   claiming success.
7. NodeProcessRunner's timeout handling now escalates: a fixed 10s after
   the timeout-triggered SIGTERM, it sends SIGKILL if the child hasn't
   exited, so a child that ignores SIGTERM (or is itself hung) can no
   longer hold run() open indefinitely.
8. Added core.DiskSpaceGuard: reserves free space minus other outstanding
   reservations (keyed per path), returning a release function, so
   concurrent installs across drivers can't each pass an instantaneous
   disk-free check and jointly overfill the volume. One shared instance is
   constructed in src/daemon/main.ts and injected into both drivers,
   replacing their bare assertDiskSpace preflight calls.
9. Driver.resolveSpec's options gained an optional requesterId, threaded
   from LeaseAcquisitionCoordinator#resolveAndDrive through both drivers'
   component-install diagnostics into the bridged component.install-*
   event payloads and the daemon's durable "Component installed" log
   line, so an install is attributable to the request that caused it.

Also updates docs/ARCHITECTURE.md (verified-fact event timing, the shared
DiskSpaceGuard, requesterId attribution, empty-runtime-catalog resolution)
and docs/EVENTS.md's payload column for the three component events.

Verified: pnpm run typecheck && pnpm run lint && pnpm run test all green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant