feat: on-demand component installation for iOS runtimes and Android system images - #69
Open
V3RON wants to merge 6 commits into
Open
feat: on-demand component installation for iOS runtimes and Android system images#69V3RON wants to merge 6 commits into
V3RON wants to merge 6 commits into
Conversation
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).
This was referenced Sep 1, 2026
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.
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.
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-downloadflag 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
downloadsconfig 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,
resolveSpecis now pairing-aware: it decodes each device type's supported OS range and each installed runtime'ssupportedDeviceTypesfromsimctl, 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 viaxcodebuild -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
DeviceProfileSourceregistry: the built-inavdmanagerlist 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'sconfig.inibefore the snapshot baseline is captured. System-image installs honor a separateacceptAndroidLicensesopt-in — license consent is never implied by download permission.Every install emits
component.install-started/installed/install-failedevents (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?
policy: "never", workstations can opt into"always", and Google's SDK licenses are only ever accepted by explicit configuration.simctl/sdkmanagerstderr dump minutes into a lease.