iOS physical devices through devicectl
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 12/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- ios, typescript
- Domain
- backend, devtools, mobile-dev
Research direction
Start with the driver in src/drivers/ios/index.ts and the planned src/drivers/ios/physical.ts, then the passthrough contract in src/core/driver.ts and the routing in src/daemon/dispatcher.ts. Depends on #428, #437, #438 and #439 being merged first, and on ADR 0022. Done when a physical iPhone can be enrolled, presence-checked, leased, exec'd through devicectl, and reclaimed, with simctl unchanged on simulators.
Written by the indexing model from the issue text.
Description
Part of #428.
Scope
After this PR, a real iPhone or iPad works end to end on a Mac worker. An operator enrolls it with simlock device add --platform ios <id>. An agent leases it, installs a build, and runs devicectl on it through device.exec. A release uninstalls the apps the agent added. A locked or unplugged iPhone becomes absent. Until now only the fake driver did this; the real iOS driver refused every physical call. This task fills the iOS driver's physical handler (ADR 0022 §5–§8), routes device.exec by the lease's device, and adds the phone lane for iOS.
In short
The iOS driver gains a physical handler that runs xcrun devicectl. It reads devices over USB only, uninstalls apps only on enrolled devices, and stops every call at a time limit. device.exec now hands the driver the lease's device, so devicectl is aimed at that device and simctl keeps serving simulators.
New terms:
-
physical handler: the part of the iOS driver that serves
physical: truedevices withdevicectl. Example: a release of00008110-001A2B3C4D5Erunsxcrun devicectl device info apps --device 00008110-001A2B3C4D5E, then onedevice uninstall appper added app. -
presence read: one
devicectl list devicesread plus onedevice info lockStateread per enrolled iPhone listed on USB. Present means all of these:- the list has an entry whose
udidis the enrolled UDID. When the UDID is listed more than once, thewiredentry is the one read; two or morewiredentries for it read not present,not-present; - its
transportTypeiswired; - its
realityis notsimulated(a missingrealitycounts as physical); - its
pairingStateispaired; - the lock read succeeds and its
passcodeRequiredisfalse.
Otherwise it is not present, with a reason:
locked(the lock read says a passcode is needed),untrusted(listedwiredbut notpaired), ornot-present(everything else). Example: an iPhone on the desk with its screen locked reads not present,locked.
devicectl also lists devices it saw before and cannot reach now, with their lasttransportType. SotunnelStateis not read: devicectl opens a tunnel on demand, and an idle phone may listdisconnected. The lock read decides instead: it fails for a phone that is not connected. Field names checked withxcrun devicectl list devices --json-output <file>(642.16). That run also showed: simulators listtransportTypesameMachine; a paired iPad overlocalNetworkhas norealityand nodeveloperModeStatus. Not checked locally, because no phone was on USB: the valuewired, and that a lock read of an unplugged phone fails. The phone lane checks both. - the list has an entry whose
-
user app (iOS): an app
devicectl device info appslists with its default filter, which lists developer-installed apps only (Xcode ordevicectlinstalls) and no system app. Example:com.example.MyAppinstalled byxcodebuild; nevercom.apple.mobilesafari. -
JSON read: every devicectl read Simlock runs for itself passes
--json-output <path>. The path is a new file in<simlockHome>/devicectl/(the driver creates it, mode 0700), one per read. Simlock reads the file back, then deletes it, also when the read fails or times out. devicectl's help says this JSON file is versioned and stable across releases, and its stdout is not. Example:xcrun devicectl list devices --json-output <temp>/devicectl-<id>.json. -
allowed list: the
device …commandsdevice.execmay run withdevicectl, each with the options it may take. Any other command, and any argument before--that is not on the command's list, is refused, "not allowed over exec". Example:device info appsruns;device reboot,list devicesanddevice info apps --json-output /tmp/xare refused.
flowchart LR
A["device add"] --> I["inspect over USB (new: devicectl)"]
I --> R[ready]
R --> L["lease --physical"]
L --> X["device.exec devicectl (new: --device UDID added)"]
X --> REL[release]
REL --> C["reclaim: uninstall added apps (new: devicectl)"]
C --> R
W["watcher: presence read (new: devicectl)"] -.-> R
Before (the real iOS driver after #439; wording illustrative):
$ simlock device add --platform ios 00008110-001A2B3C4D5E
{"error":{"code":"DEVICE_NOT_ENROLLABLE","message":"this driver cannot read physical devices yet"}}
After:
$ simlock device add --platform ios D39C0F5B-3E40-5848-8E7E-85A32473C183
Enrolled iPhone 15 Pro (iOS 18.4) as 00008110-001A2B3C4D5E, 3 user apps recorded.
$ simlock device add --platform ios 00008030-0011223344556677
Refused: physical iOS devices need iOS 17 or later.
$ simlock status # after the leased iPhone was locked for the grace period
Device dev_7: absent (locked), physical phone
$ curl -X POST .../v1/leases/lse_9/exec -d '{"tool":"devicectl","args":["device","install","app","/builds/MyApp.app"]}'
event: exit data: {"exitCode":0} # ran: xcrun devicectl device install app --device 00008110-… /builds/MyApp.app
$ curl -X POST .../v1/leases/lse_9/exec -d '{"tool":"devicectl","args":["--json-output","/tmp/x","list","devices"]}'
422 {"error":{"code":"PASSTHROUGH_REFUSED", ...}}
| Input (real iOS driver) | After #439 | After |
|---|---|---|
device add of an iPhone on USB, trusted, Developer Mode on, unlocked |
refused: no physical support | enrolled under its UDID, user apps recorded |
device add with its CoreDevice ID, ECID or serial number |
refused | enrolled under its UDID |
device add of an ID listed twice, once on USB and once over Wi-Fi |
refused | read from the USB entry |
device add of an iPhone on iOS 16 |
refused | refused: needs iOS 17 or later |
device add of an Apple TV, Watch, Vision device or Mac |
refused | refused: not supported |
device add of a simulator's UDID |
refused | refused: not a physical device |
device add of an iPhone seen only over Wi-Fi |
refused | refused: connect it by USB |
device add of an iPhone not paired |
refused | refused: not trusted |
device add with Developer Mode off |
refused | refused: Developer Mode is off |
device add of a locked iPhone |
refused | refused: locked, unlock it and try again |
| enrolled iPhone locked, untrusted, or unplugged for the grace period | stays ready (no real presence read) |
absent (locked), absent (untrusted), absent (not-present) |
device add of an ID devicectl does not list |
refused | refused: not connected |
device add while a devicectl read fails, times out, or lacks a field, or of an ID that matches two devices on USB |
refused | refused: could not read the device, naming the read (unreadable) |
| release of a physical iOS lease | fake driver only | apps not on the enrollment list uninstalled; missing listed apps reported |
device.exec devicectl device install app <path> on a physical lease |
UNKNOWN_PASSTHROUGH_TOOL |
runs xcrun devicectl device install app --device <UDID> <path> |
device.exec devicectl device process launch <bundle> -- -AppleLanguages (en) on a physical lease |
UNKNOWN_PASSTHROUGH_TOOL |
runs; the arguments after -- reach the app unchecked |
device.exec devicectl device reboot, list devices, or any command not on the allowed list |
UNKNOWN_PASSTHROUGH_TOOL |
PASSTHROUGH_REFUSED: not allowed over exec |
device.exec devicectl device install app <path> --json-output <file>, or any option not on the command's list before -- |
UNKNOWN_PASSTHROUGH_TOOL |
PASSTHROUGH_REFUSED: not allowed over exec |
device.exec devicectl on a simulator lease |
UNKNOWN_PASSTHROUGH_TOOL |
PASSTHROUGH_REFUSED |
device.exec simctl on a physical lease |
runs simctl on the device set | PASSTHROUGH_REFUSED |
device.exec simctl on a simulator lease |
runs | unchanged |
driver.passthrough (local simlock simctl) |
runs | unchanged |
driver.passthrough with tool devicectl (no lease) |
UNKNOWN_PASSTHROUGH_TOOL |
PASSTHROUGH_REFUSED: run xcrun devicectl directly |
If this goes wrong, an operator would see a release that leaves an agent's app on the phone, a system or pre-enrolled app gone, a phone that never leaves absent, or a devicectl command run on the wrong device.
Technical spec
Modules touched
flowchart LR
DSP["daemon/dispatcher *"] --> CAT["core/driver-catalog *"]
SRV["daemon/server *"] --> CAT
MAIN["daemon/main *"] --> IOS
CAT --> IOS["drivers/ios *<br/>simulator + physical handler"]
CAT --> AND["drivers/android * (signature only)"]
LEAS["leasing: enroll, reclaim, watcher (unchanged)"] --> IOS
IOS --> PR["ports: ProcessRunner, Clock, Filesystem"]
PR --> DC["xcrun devicectl"]
* marks a changed module.
-
src/drivers/ios/index.ts(or a newsrc/drivers/ios/physical.tsthe driver delegates to;index.tsstays the module's surface):-
Routing. Every shared method branches on
device.physical. The virtual branch is today's code, unchanged. -
inspectPhysical(id). Onelist devicesread. It matchesidagainst each entry'sidentifier(CoreDevice ID),udid,ecidandserialNumber:identifier,udidandserialNumbermatch as strings, ignoring letter case;ecidis a JSON number. It matchesidwritten as decimal digits, or as0xand hex digits (any case). It is compared as an exact integer, read from the raw JSON text, so no digit is lost above 2^53;- a name or DNS name is not an ID and does not match (safety rule 8).
When
idmatches more than one entry, thewiredentry is the one checked. Two or morewiredmatches are refusedunreadable, "matches more than one device", and no lock read runs. When none of the matches iswired, the first match in devicectl's list order is the one checked.It then refuses, in this order, and stops at the first that applies:
- no match: not connected (
not-connected); transportTypeissameMachine, orrealityissimulated: not a physical device (virtual-device);transportTypeis notwired: connect it by USB (network-only);platformis notiOS, ordeviceTypeis notiPhoneoriPad: not supported (unsupported-kind);- OS major below 17, or
isMobileDeviceOnlytrue: needs iOS 17 or later (os-too-old). A missingisMobileDeviceOnlycounts asfalse; pairingStateis notpaired: not trusted (untrusted);developerModeStatusis notenabled: Developer Mode is off (developer-mode-off);- the lock read says a passcode is needed: "locked: unlock it and try again" (
locked). The lock read runs only when every check above passed.
A field is required only from the
wiredstep on. Before that step, a missing field refuses nothing: a missingrealityis notsimulated, and a missingtransportTypeis notsameMachine. From that step on, the matched entry needstransportType,udid,platform,deviceType,osVersionNumberandpairingState, anddeveloperModeStatusonce the earlier checks pass. A missing one, or one whose JSON type is not the one read (a string, or a number forecid), is refusedunreadable. So the Wi-Fi iPad from the 642.16 run, with norealityand nodeveloperModeStatus, is refusednetwork-only. The lock read needs a booleanpasscodeRequired.A read that fails, times out, exits non-zero, or leaves a JSON file that is missing or does not parse is refused
unreadable(#437's reason, ADR 0022 §6), with a message naming the read (list devices,lockStateorapps). The reason codes are #437'sDEVICE_NOT_ENROLLABLEreasons.inspectPhysicalnever rejects for a failed read.Otherwise it reads user apps with
device info apps --device <UDID>, default filter, no--include-*flag. It returns UDID, model (marketingName, elseproductType), OS version (osVersionNumber), class (iPadistablet, elsephone) and the bundle IDs. A failed apps read is refusedunreadable. It never returns an empty app list in place of a failed read. It runs only read verbs:list devices,device info lockState,device info apps. -
listPresentPhysical(enrolledIds). Onelist devicesread. It matches entries byudidonly; an entry with noudidis ignored. For a given UDID listed more than once, thewiredentry is the one read; two or morewiredentries answer that UDID not present,not-present, with no lock read. Then one lock read for each given UDID whose entry iswired,pairedand notsimulated. The lock reads run in parallel, so they take one read limit in all, not one each. It answers one entry per given UDID and none for any other device: present with its OS version (osVersionNumber), as the presence read above defines it; or not present withlocked,untrustedornot-present. A list entry with noosVersionNumberanswers its UDIDnot-present. A failed or timed-out list read answers every given UDID not present,not-present. A lock read that fails, times out, leaves no JSON, or has no booleanpasscodeRequiredanswers that UDID not present,not-present. It never rejects for a failed read. -
reclaim(physical). One apps read. Then, by #438'suninstallPlan, onedevice uninstall app --device <UDID> <bundleId>for each listed bundle ID not onenrolledApps, one at a time, stopping at the first failure. It returns strategyuninstallandmissingApps: the enrolled bundle IDs the read no longer lists. A failed read or uninstall rejects, and the core's failed-reclaim path (#438, #439) decides absent or quarantined. It never uninstalls a bundle ID the read did not list. -
estimate. For a physical reclaim it returns 90 000 ms at both clean levels: one 30 s read limit plus one 60 s uninstall limit. Two callers read it: a grant'sestimatedReclaimMs(src/leasing/lease-acquisition-coordinator.ts:1128), and doctor's stall threshold (src/core/doctor.ts:908-909), which only applies to a reclaim no live claim holds. -
passthroughTools. It becomes["simctl", "devicectl"]. -
passthrough(tool, device, args, context)(async, below):simctlwith no device or a virtual device: today's command and refusals, unchanged.simctlwith a physical device: refused.devicectlwith a virtual device: refused.devicectlwith no device (driver.passthrough): refused; the message says to runxcrun devicectldirectly.devicectlwith a physical device:xcrun devicectl <args>, with noenventries of its own. The checks below run before anything is spawned. When no-d,--deviceor--device=…appears before the first--, it inserts--device <UDID>right after the command's three words. A-dafter--belongs to the app and does not count. A caller's own--deviceis left as it is: naming another device is not stopped (ADR 0022 §5).
-
devicectl allowed list (
device.exec, ADR 0022 §5, safety rule 10). The check is structural. It reads every argument before the first--. The arguments after the first--pass unchecked: devicectl reads them as positional arguments, never as its own options (checked with 642.16 and a dummy device:device info details --device <id> -- --json-output <path>exits 64 and writes no file). devicectl reads its own options anywhere before--, also after a positional argument, so the check does too.- Refused anywhere before the first
--, also as another option's value:-j,--json-output,-l,--log-output,--destination,--fileand--filter, each alone or as--flag=…, and any single-dash cluster that holdsjorl(-vj). The first six name a path on the host. devicectl evaluates a--filterpredicate on the host. args[0..2]must be one of these nine commands, exactly. So any global option before the command is refused, and so arelist …,manage …anddiagnose.- After the command, each argument before the first
--must be one of: an option on the command's list below; the value of the option just before it, when that option takes one (--option value, or--option=valuefor a long option); or one of the command's positional arguments, up to its count. Anything else is refused. That covers an unknown option, a short-option cluster (-vq,-qd <id>), a short option with its value attached (-d<id>; devicectl 642.16 does not accept that form either), and a positional argument past the count.
The nine commands, their positional arguments, and their options, from
xcrun devicectl <command> --help(642.16). Every command also takes the common options: with a value,-d/--deviceand-t/--timeout; without,-v/--verbose,-q/--quiet,--omit-deprecated-fields-in-json,-h/--helpand--version.Command Positional Options with a value Options without a value device install app<path>, a path on the worker by design: "install only a build already on the worker"--columns--hide-default-columns,--hide-headersdevice uninstall app<bundle-identifier>device process launch<bundle-identifier-or-path>; the app's own arguments go after---u/--user,-e/--environment-variables,--working-directory,--arch,--payload-url,--display,--launch-persistent-identifier--start-stopped,--activate,--no-activate,--terminate-existing,--consoledevice process terminatenone -p/--pid--killdevice process signalnone -p/--pid,-s/--signaldevice info appsnone --bundle-id,-s/--search,--sort-by,--columns--include-app-clips,--no-include-app-clips,--include-removable-apps,--no-include-removable-apps,--include-default-apps,--no-include-default-apps,--include-all-apps,--require-container-access,--include-app-group-identifiers,--include-container-paths,--hide-default-columns,--hide-headersdevice info lockStatenone device info detailsnone device info processesnone -s/--search,--sort-by,--columns--hide-default-columns,--hide-headersAnything else is refused with
PASSTHROUGH_REFUSEDand the message "not allowed over exec: is not on the reviewed list" (or names the argument that is not). Each listed command takes--device. The list was built by walkingxcrun devicectl <path> --helpfor devicectl 642.16 (Xcode 27.0): 80 leaves, 70 of them underdevice. Not on the list, among others: the fourdevice …leaves that take no--device(device pairings pair,device pairings set-active,device pairings unpair,device profile validate), every leaf with a host-path flag (device capture screenshotandscreen-record --destination,device sysdiagnose --destination,device pasteboard copy --file,device copy to|from), the host-pasteboard leaves (device pasteboard transfer to|from,device pasteboard sync-with-host), and the leaves that change the device beyond its apps (device reboot,device rename,device settings …,device profile install|remove). - Refused anywhere before the first
-
Time limits. Every devicectl call goes through
#invokeXcrunwith its own limit: 30 s for a read, 60 s for an uninstall. A read past its limit counts as not present. An uninstall past its limit is a reclaim failure. No lock or queue is shared with the simctl calls. -
JSON. Every devicectl read passes
--json-output <path>, a new file in<simlockHome>/devicectl/(the driver creates it, mode 0700), one per read, so two lock reads in parallel never share a file. After the call ends, the driver reads the file through itsFilesystemport and deletes it. It deletes it also after a non-zero exit or a timeout. A missing file, a file that does not parse as JSON, a non-zero exit, or a field shape that does not parse is a failed read. The parser readsresult.devices[]. It reads each field from its deprecated key, and from itspropertieskey only when the deprecated key is absent. devicectl 642.16 still writes both:Field Deprecated key Else udid,reality,platform,deviceType,marketingName,productType,ecid,serialNumberhardwareProperties.<field>properties.hardware.<field>osVersionNumberdeviceProperties.osVersionNumberproperties.software.osVersionNumberdeveloperModeStatusdeviceProperties.developerModeStatusproperties.state.developerModeStatustransportType,pairingStateconnectionProperties.<field>properties.connection.<field>isMobileDeviceOnlyconnectionProperties.isMobileDeviceOnlynone: absent counts as falseidentifieris read from the entry itself.
-
-
src/core/driver.ts:376, 389:passthroughToolbecomespassthroughTools: readonly string[].passthroughtakes the tool and the lease's device (VirtualDriverDevice | PhysicalDriverDevice | undefined), as in ADR 0022 §5. It also becomes async: it returnsPromise<PassthroughCommand>, and a refusal rejects withPassthroughRefusedError. This task changes the signature, so it makes the change; #441 needs it, because its adb protocol check awaits a read before every physical adb command.DriverRejection.passthroughTool(:516) becomespassthroughTools. -
src/core/core-ports.ts:40PassthroughResolverandsrc/core/driver-catalog.ts:74-84: they take the device, return a promise, and route to the driver whosepassthroughToolsholds the tool. The catalog awaits the driver'spassthrough(:81). An unknown tool rejects withUnknownPassthroughToolError. -
src/daemon/dispatcher.ts:#driverPassthrough(:514-519, the call at:518) passes no device and returns the promise. ItsHandlertype already allows one (:198), andrunDispatchawaits it (src/daemon/dispatch.ts:316).#deviceExec(:539-566, the call at:555) finds the lease's device record and passes it, converted with #438's exportedtoDriverDevice(architecture rule 10). It awaits the command before it spawns anything.
-
Every other site of the rename and the new signature. The list is complete for
grep -rn "passthroughTool\|passthrough(" src e2eonmainatac4cf1e:src/leasing/testing.ts:149(the test harness's resolver);src/daemon/main.ts:1013(iOS root rejection:simctlanddevicectl),:1060and:1078(Android rejections:adb),:1091-1094(rootRejectiontakes the list);src/daemon/server.ts:869-871: matches a tool againstpassthroughTools;src/drivers/ios/index.ts:1522, 1532, 1567, 1595, 1604;src/drivers/android/index.ts:642, 650, 667, 673, 682, 690:passthroughTools: ["adb"]and the new async signature. The device argument is ignored until #441. No real Android physical device can be enrolled before #441;src/core/fake-driver.ts:112, 177, 209, 549, 552ande2e/fake-driver/fake-driver.ts:199, 215, 521, 528, 535-536: the same rename and the async signature; the fake keeps its behaviour;- tests that name either:
src/drivers/ios/index.test.ts,src/drivers/android/index.test.ts,src/core/driver-catalog.test.ts,src/daemon/dispatcher.test.ts,src/daemon/server.test.ts,src/daemon/main.test.ts,src/daemon/gateway-fleet.e2e.test.ts. A refusal test moves fromtoThrowtorejects.toThrow.
-
src/ports/process-runner.ts:605ScriptedProcessExpectation: an optional callback that runs when the scripted call is spawned, given its args. The driver tests use it to write a devicectl read's JSON into the test'sMemoryFilesystemat the path the--json-outputargument names. -
src/drivers/ios/fixtures/:devicectl-list-devices.json: hand-made in the shape of a real devicectl 642.16 run on a Mac with no phone on USB. That run listed simulators (sameMachine), a paired iPhone overlocalNetwork, and a paired iPad overlocalNetworkwith norealityand nodeveloperModeStatus; those entries are kept. A wired iPhone, a wired unpaired iPhone and a wired iPad are edited in. The phone lane confirms thewiredshape.devicectl-info-apps.jsonanddevicectl-lock-state.json: hand-written. No real run made them, because no phone was on USB. They hold the keys this spec reads (result.apps[].bundleIdentifier, a booleanpasscodeRequired), and the phone lane confirms them.
-
Phone lane wiring (M5, ADR 0022 §11). Two tags:
physical: tests that need no person (enroll, lease, install, release, presence of a plugged-in unlocked phone). They run unattended when the env vars name a device;physical-manual: tests that need a person (lock, unplug, replug). They run only whenSIMLOCK_PHYSICAL_PERSONis1, which only a person running them by hand sets. The hardware verifier never sets it, so it never runs them.
Changes:
vitest.config.tsdeclares both tags;package.json:test:e2e:slowadds--tags-filter='!physical && !physical-manual'. Newtest:e2e:physicalruns onlyphysical, and newtest:e2e:physical-manualruns onlyphysical-manual, both with--no-file-parallelism;scripts/slow-e2e.sh --physical <files>runstest:e2e:physical, and--physical-manual <files>runstest:e2e:physical-manual, under the same machine lock. A person follows the manual run's prompts in its log.
-
docs/internal/agent-rules/toolchain.md, slow lane: two rows in the machine-check table.- "Physical iOS":
uname -s=Darwin,SIMLOCK_PHYSICAL_IOSset to the UDID of an iPhone on USB (trusted, Developer Mode on, unlocked, Auto-Lock Never), andSIMLOCK_PHYSICAL_IOS_APPset to a.appsigned for it. It namesscripts/slow-e2e.sh --physical e2e/slow-ios-physical.test.ts. - "Physical iOS, a person at the phone": the checks above, a passcode set on the phone (locking it then needs the passcode), and
SIMLOCK_PHYSICAL_PERSON=1. It namesscripts/slow-e2e.sh --physical-manual e2e/slow-ios-physical-manual.test.ts.
A paragraph says what the two tags mean, as above:
physicalruns unattended;physical-manualprompts a person in its log and waits up to 150 s for each step. Only a person running it by hand setsSIMLOCK_PHYSICAL_PERSON. The hardware verifier never does, so this machine check fails and it reportsunavailable (needs a person), its existing outcome for a failed check (.claude/agents/hardware-verifier.md:20-21). No agent file changes. - "Physical iOS":
-
docs/CLI.mdPrerequisites gets a "Physical iOS devices" list:- Xcode 15 or later (it ships
devicectl); checked with Xcode 27 (devicectl 642.16); - iOS or iPadOS 17 or later;
- the device trusts this Mac ("Trust This Computer");
- Developer Mode on;
- connected by USB;
- unlocked: a device whose passcode is needed counts as not present, and
statusshows itabsent (locked), so set Auto-Lock to Never or use no passcode.
"Reaching a leased device" (#438 added the
SIMLOCK_DEVICE_UDIDrow) gets the examplexcrun devicectl device info apps --device "$SIMLOCK_DEVICE_UDID", and says a remote agent runs devicectl throughdevice.execover HTTP or the client: there is nosimlock devicectlcommand. - Xcode 15 or later (it ships
One device.exec through a worker:
sequenceDiagram
participant A as Agent (HTTP)
participant D as Worker dispatcher
participant C as DriverCatalog
participant I as iOS driver
participant X as xcrun devicectl
A->>D: device.exec {leaseId, tool: devicectl, args}
D->>D: lease -> device record (UNKNOWN_LEASE if none)
D->>C: await passthrough(devicectl, device, args)
C->>I: await passthrough(devicectl, device, args)
alt refused (not on the allowed list, host-path option before --, virtual lease ...)
I-->>A: PASSTHROUGH_REFUSED {tool: devicectl}, nothing spawned
else allowed
I-->>D: xcrun devicectl ... --device UDID
D->>X: spawn (exec.timeoutMs)
X-->>A: output chunks, then exit code
end
One enrollment:
sequenceDiagram
participant O as Operator CLI
participant L as Daemon (enrollment, #437)
participant I as iOS driver
participant X as xcrun devicectl
O->>L: device.add {platform: ios, id}
L->>I: inspectPhysical(id)
I->>X: list devices --json-output <temp file> (30 s)
I->>X: device info lockState --device UDID --json-output <temp file> (30 s, only when every earlier check passed)
alt not listed, simulated, Wi-Fi, unsupported, iOS < 17, untrusted, Developer Mode off, locked
I-->>O: refused, with the reason
else a read failed, timed out, or lacks a field; or two wired entries match
I-->>O: refused unreadable, naming the read
else ok
I->>X: device info apps --device UDID --json-output <temp file> (30 s)
I-->>L: UDID, model, OS, class, bundle IDs (apps read failed: refused unreadable)
L-->>O: enrolled
end
Contract and event changes
None. device.exec and driver.passthrough keep tool as an open string. PASSTHROUGH_REFUSED already carries tool. passthroughTools and the async passthrough signature are internal driver types, not contract shapes. Events (device.enrolled, device.reclaimed with uninstall and missingApps, device.absence-detected) were added by #437, #438 and #439. This task only fills their payloads from real reads.
Other code on the same state
- Apps on an enrolled iPhone. The writers are: reclaim (#438,
src/core/reclaim-coordinator.ts); the quarantine retry (src/core/quarantine-coordinator.ts, which callsreclaim); enrollment's read (#437); the watcher's reads (#439); and a lease holder'sdevice.exec. The first four hold the device's operation claim (src/core/device-operation-claims.ts), so they never overlap on one device. The watcher only reads, and never transitions a claimed device. Adevice.execstarted before release is not claimed and can outlive its lease (KNOWN-PITFALLS "Adevice.execcommand is authorized once"). An install it finishes after the reclaim's apps read survives until the next release. The straggler wins; this is accepted as it is for simulators. - Tool routing.
DriverCatalog.passthrough(driver-catalog.ts:74-84) is the one place a tool name picks a driver.DaemonServer#rejectionFor(server.ts:869-871) explains a missing tool fromDriverRejection. Both move topassthroughToolsin this PR, so no copy keeps the old single name. - CLI wrappers.
src/cli/index.ts:631-647publishessimctlandadbonly. It is unchanged: there is nosimlock devicectlcommand. A local agent runs plainxcrun devicectl; a remote one usesdevice.execover HTTP or the client. The gateway path is #442. - Gateway.
src/gateway/dispatcher.ts:505forwardsdevice.execwith the tool name unread. The worker resolves it, so nothing changes there in this PR (#442). - Presence timing. One presence read takes at most 60 s: 30 s for the list, then 30 s for the parallel lock reads. The default
health.probeIntervalMsis 30 s. #439's watcher arms its next tick only after the previous one settles; a grant's read (#438) can run beside it, and this driver allows concurrent reads. - Simulator calls.
#invokeSimctland#invokeXcrun(src/drivers/ios/index.ts:1787-1834) share only the process runner and clock. A hung devicectl call holds no simctl call. - JSON files. Each devicectl read writes its own new file and the driver deletes it. No other component reads or writes these files. A caller's
device.execcan never name one:--json-outputis refused there.
Rules in play
safety.mdrules 1, 7 and 8 as amended by ADR 0022 §12. The only destructive call isdevice uninstall app. It targets only a UDID from aPhysicalDriverDevice, and only for a bundle ID the fresh read listed and the enrollment list lacks. Lock reads run only for enrolled UDIDs.safety.mdrule 10:device.execarguments are claims. Only commands and options on the allowed list run, and the check runs before anything is spawned.architecture.mdrules 2 and 3: every devicectl fact (verbs, JSON keys, the allowed list) stays insrc/drivers/ios/. Rule 10: one tool-routing place and one record-to-DriverDevicefunction. Rule 11: every devicectl call is bounded. Rule 9: devicectl goes through the existingProcessRunnerport, and its JSON files through theFilesystemport.testing.mdrules 1–4. A refusal test asserts that nothing was spawned, not only the error.toolchain.md: the slow lane and its two new rows.
Tests
Seam: IosSimctlDriver built with ScriptedProcessRunner, FakeClock and MemoryFilesystem, modelled on src/drivers/ios/index.test.ts. Each scripted devicectl read writes its JSON into the MemoryFilesystem at its --json-output path, through the new spawn callback. Core routing uses src/core/driver-catalog.test.ts, src/daemon/dispatcher.test.ts and src/daemon/main.test.ts (discoverIos). The phone lane is e2e/slow-ios-physical.test.ts and e2e/slow-ios-physical-manual.test.ts, modelled on e2e/slow-ios-smoke.test.ts and e2e/device-exec.test.ts. Lane wiring is a new unit test, src/test-lanes.test.ts, which reads package.json.
Driver, enrollment:
inspectPhysicalwith a CoreDevice ID, an ECID or a serial number returns the device's UDID, and every later call names the UDIDinspectPhysicalmatches a UDID, CoreDevice ID and serial number written in the other letter case, and an ECID written in decimal and as0xhexinspectPhysicalmatches an ECID above 2^53 by its exact digits, and not an ECID one apartinspectPhysicalwith a device's name matches nothing and is refused as not connectedinspectPhysicalof an ID listed once overlocalNetworkand oncewiredreads thewiredentry and enrolls itinspectPhysicalof an ID that matches twowiredentries is refusedunreadablewith "matches more than one device", and runs no lock readinspectPhysicalof an ID that matches two entries, neitherwired, answers the refusal of the first one in the list (a Wi-Fi entry before asameMachineone givesnetwork-only)inspectPhysicalreturnsmarketingNameas model,osVersionNumberas OS version,tabletfor an iPad andphonefor an iPhoneinspectPhysicalfalls back toproductTypewhenmarketingNameis absentinspectPhysicalreturns the bundle IDsdevice info appslists, and the apps read passes no--include-*flaginspectPhysicalrefuses each case in the enrollment table above with its reason code: not connectednot-connected, simulatedvirtual-device,sameMachinevirtual-device, Wi-Fi onlynetwork-only, not supported (tvOS, watchOS, visionOS, macOS)unsupported-kind, below iOS 17 andisMobileDeviceOnlyos-too-old, not paireduntrusted, Developer Mode offdeveloper-mode-off, lockedlockedwith "locked: unlock it and try again"- the first check in the order wins, for each pair of neighbours: a simulated entry over
localNetworkisvirtual-device; an Apple TV over Wi-Fi isnetwork-only; a wired Apple TV on tvOS 16 isunsupported-kind; a wired, unpaired iPhone on iOS 16 isos-too-old; a wired, unpaired iPhone with Developer Mode off isuntrusted; a wired iPhone with Developer Mode off that is locked isdeveloper-mode-off inspectPhysicalruns no lock read when an earlier check refusesinspectPhysicalof the fixture's Wi-Fi iPad, which has norealityand nodeveloperModeStatus, is refusednetwork-only, notunreadable; an iPad over Wi-Fi on iOS 16 isnetwork-onlytooinspectPhysicalof an entry over Wi-Fi with noplatformor nopairingStateis refusednetwork-only: no field is required before thewiredstepinspectPhysicalof aphysicalentry with notransportTypeis refusedunreadableinspectPhysicalof awiredentry that lacksudid,platform,deviceType,osVersionNumberorpairingState, or that lacksdeveloperModeStatusonce the earlier checks pass, or whose lock read lacks a booleanpasscodeRequired, is refusedunreadableinspectPhysicalof an entry with noisMobileDeviceOnlyreads it asfalseinspectPhysicalwhose list read, lock read or apps read exits non-zero, leaves no file, leaves a file that is not JSON, or passes 30 s is refusedunreadablewith a message naming that read, returns no app list, and does not rejectinspectPhysicalruns onlylist devices,device info lockStateanddevice info apps- the parser reads the same device from the deprecated keys and from
propertiesalone, field by field as the table above maps them
Driver, presence:
listPresentPhysicalanswers a paired, wired, unlocked given iPhone present, with its OS versionlistPresentPhysicalanswers a paired, wired, unlocked given iPhone with norealitypresentlistPresentPhysicalanswers a locked iPhone not presentlocked, an unpaired oneuntrusted, and a Wi-Fi-only one and one not listednot-presentlistPresentPhysicalgives no entry for, and never runs a lock read for, a physical device not among the UDIDs it was givenlistPresentPhysicalignores a list entry with noudid: it runs no lock read for it, and every given UDID reads as it would without that entrylistPresentPhysicalof a given UDID listed once over Wi-Fi and oncewiredreads thewiredentry; one listedwiredtwice readsnot-presentwith no lock readlistPresentPhysicalwhose list read exits non-zero, leaves no file or a file that is not JSON, or passes 30 s answers every given UDID not presentnot-present, and does not reject- one lock read past 30 s answers that device not present
not-presentand the others as read - a lock read that exits non-zero, leaves no file or a file that is not JSON, or lacks a boolean
passcodeRequiredanswers that device not presentnot-present - two lock reads that each take 25 s (fake clock) both finish, and
listPresentPhysicalresolves before 30 s have passed after the list read: they run in parallel - a list entry for a given UDID with no
osVersionNumberanswers that UDID not presentnot-present - a
wired,paired,physicalentry withtunnelStatedisconnectedand a lock read that answerspasscodeRequired: falsereads present; the same entry whose lock read fails reads not presentnot-present
Driver, reclaim:
reclaimof a physical device uninstalls each listed bundle ID missing fromenrolledApps, with--device <UDID>, and returns strategyuninstallreclaimuninstalls nothing on the enrollment list, and nothing the apps read did not listreclaimreports enrolled bundle IDs the read no longer lists asmissingApps, and installs nothingreclaimwhose apps read fails rejects and uninstalls nothingreclaimwhose uninstall exits non-zero, or passes 60 s, rejects and runs no further uninstallestimateof a physical reclaim is 90 000 ms at both clean levels; a virtual reclaim's estimate is unchanged
Driver, files and limits:
- every devicectl read passes
--json-output <path>naming a new file in<simlockHome>/devicectl/(the driver creates it, mode 0700), and the driver reads that file and then deletes it - the file is deleted also after a read that exits non-zero or passes its limit
- two lock reads in parallel name two different files
- a read whose file holds a field of the wrong JSON type (
passcodeRequiredas a string,udidas a number) is a failed read - a devicectl read that hangs does not delay a simctl call made at the same time
Driver, passthrough and environment:
passthroughToolsissimctlanddevicectlpassthroughis async: it resolves the command, and every refusal rejects withPassthroughRefusedErrordevicectl device install app <path>on a physical device inserts--device <UDID>right afterapp, and returns noenventriesdevicectl device process launch <bundle> --consoleinserts--device <UDID>before the bundle IDdevicectlargs that carry--device,-dor--device=before--are passed as givendevicectl device process launch <bundle> -- -d xinserts--device <UDID>: a-dafter--does not count- each of the nine allowed commands runs, with
--device <UDID>inserted - each option in the table runs on its command: an option with a value as
-o valueor--option value, a long one also as--option=value(device process signal --pid 12 --signal SIGTERM,device process launch -e '{"A":"1"}' <bundle>), and each option without a value device process launch <bundle> -- -AppleLanguages (en)runs with the arguments after--unchanged;device process launch <bundle> -AppleLanguages (en)is refuseddevicectlwith a global option before the command (-v,-q,-t 5,--timeout 5,-j -,--json-output -,-l x,--omit-deprecated-fields-in-json) is refusedlist devices,manage pair,diagnose,device reboot,device copy from,device capture screenshot,device pasteboard copy,device pasteboard sync-with-host,device pairings pairanddevice profile validateare refused "not allowed over exec", with and without--device--json-output,--json-output=x,-j,-vj,--log-output,-l,--destination,--fileand--filterare refused right after the command, after the positional argument (device install app <path> --json-output x), and as another option's value (device info apps --search --json-output x)--json-output xafter the first--(device process launch <bundle> -- --json-output x) runs: it is the app's argumentdevice info apps --filter <predicate>anddevice info processes --filter <predicate>are refused- an unknown option (
--foo), a short-option cluster (-vq,-qd <id>), an attached short value (-d<id>), and a second positional argument before--(device uninstall app a b,device process launch <bundle> extra) are refused - an option that is on another command's list but not on this one's (
device info details --kill) is refused - every refusal rejects with
PassthroughRefusedErrornamingdevicectl, and the process runner starts nothing devicectlon a virtual device is refused, and with no device it is refused with a message namingxcrun devicectlsimctlon a physical device is refusedsimctlwith no device and with a virtual device gives today's command and today's refusalsadbpassthrough on the Android driver gives today's command with and without a device, now awaitedsrc/core/fake-driver.tsande2e/fake-driverresolve today's passthrough command and reject today's refusals
Core routing:
- (catalog)
passthrough("devicectl", …)resolves the command of the driver whosepassthroughToolsholds it; an unknown tool rejects withUnknownPassthroughToolError - (server) a
devicectlcall when the iOS driver was rejected at startup names the iOS rejection's summary - (main)
discoverIoswith a rejected root returns a rejection whosepassthroughToolsaresimctlanddevicectl; an Android rejection's areadb - (dispatcher)
device.execpasses the lease's device topassthrough;driver.passthroughpasses none - (dispatcher) a
passthroughthat rejects withPassthroughRefusedErroranswersPASSTHROUGH_REFUSEDondriver.passthroughand ondevice.exec, anddevice.execspawns nothing - (dispatcher)
device.execwith an unknown lease isUNKNOWN_LEASEand resolves no command, as today
Phone lane. Both files are skipped when SIMLOCK_PHYSICAL_IOS or SIMLOCK_PHYSICAL_IOS_APP is unset, and the manual file also when SIMLOCK_PHYSICAL_PERSON is not 1. The skipped title names the missing variable. Health is set to probeIntervalMs 2000 and stableObservations 2. The lane confirms the devicectl facts the driver tests assume, which were read from devicectl 642.16 help and one list devices run with no phone on USB: a wired phone reads transportType: wired; device info lockState writes a passcodeRequired boolean to its JSON file; device info apps writes result.apps[].bundleIdentifier; --device inserted after the command works for process launch with app arguments after --.
e2e/slow-ios-physical.test.ts (tags slow, ios, physical; no person needed):
- enrolling by the phone's CoreDevice ID shows the device in
simlock list --devicesunder its UDID, marked physical - the plugged-in, unlocked phone reads present: it stays
readyacross three probe intervals, and the test logs thetunnelStateits list entry showed - an app installed before enrollment is still installed after a lease and release
- after re-enrolling without that app, an install through
device.execdevicectl device install app <APP>(no--device) exits 0, and after release the app is gone and the device is ready device.execdevicectl --json-output <path> list devicesis refused and no file appears at<path>device.execdevicectl device process launch <bundle> -- -AppleLanguages (en)of the test app exits 0- the grant's environment carries
SIMLOCK_DEVICE_UDIDequal to the phone's UDID - the file ends with
device removeand removes the test app
e2e/slow-ios-physical-manual.test.ts (tags slow, ios, physical-manual; a person follows prompts in the log, and the test waits up to 150 s for each):
- after the prompt "lock the phone", the device becomes
absentwithabsentReasonlockedwithin 150 s, and once unlocked it is reclaimed back to ready - after the prompt "unplug the phone" during a lease, the lease ends
device-lostand the device becomesabsentwithabsentReasonnot-presentwithin 150 s (so a lock read of an unplugged phone fails), and once it is plugged in and unlocked it is ready again - the file ends with
device remove
Lane wiring (src/test-lanes.test.ts, reads package.json):
test:e2e:slow's--tags-filterexcludesphysicalandphysical-manual, andtest:e2e's excludesslow, which both phone files carrytest:e2e:physical's--tags-filterselectsphysicaland notphysical-manual;test:e2e:physical-manual's selectsphysical-manualonly; both pass--no-file-parallelism
Done when
- On a Mac with an iPhone on USB (trusted, Developer Mode on, Auto-Lock Never) and a
.appsigned for it,scripts/slow-e2e.sh --physical e2e/slow-ios-physical.test.tspasses, unattended. It proves: enroll by CoreDevice ID under the UDID; the plugged-in phone reads present; install throughdevice.exec; release removes the app and keeps the pre-enrolled one. This needs real hardware. - On the same Mac, with a passcode set on the phone and a person at it,
SIMLOCK_PHYSICAL_PERSON=1 scripts/slow-e2e.sh --physical-manual e2e/slow-ios-physical-manual.test.tspasses: lock givesabsent (locked); unplug givesdevice-lostthenabsent (not-present); return gives ready. This needs real hardware and a person. The hardware verifier reports itunavailable (needs a person), and the maintainer runs it. - Both commands with
SIMLOCK_PHYSICAL_IOSunset skip, and the skipped title names the variable. device.execdevicectlwith a command not on the allowed list, a global option before the command, an option not on the command's list, or a host-path option anywhere before--answersPASSTHROUGH_REFUSEDand spawns nothing (driver tests).- A devicectl read that fails, times out or lacks a field, or an ID that matches two wired devices, refuses
device addasunreadable(driver tests). simctlon a physical lease anddevicectlon a simulator lease answerPASSTHROUGH_REFUSED(driver tests).simlock simctlon a simulator works as before.passthroughis async on every driver, anddriver.passthroughanddevice.execstill answerPASSTHROUGH_REFUSEDfor a refusal (driver and dispatcher tests).- Each enrollment refusal in the table above has its reason code, and the first check in the order wins (driver tests).
listPresentPhysicalanswerslocked,untrustedandnot-presentas the presence read defines them, and every devicectl read's JSON file is deleted after the read, also after a failure or a timeout (driver tests).- A devicectl read past 30 s counts as not present, and an uninstall past 60 s fails the reclaim (driver tests with the fake clock).
test:e2e:slowandtest:e2einpackage.jsonselect nophysicaland nophysical-manualtest (lane wiring unit test).toolchain.mdhas the "Physical iOS" and "Physical iOS, a person at the phone" rows, anddocs/CLI.mdPrerequisites lists the physical iOS requirements above.
Out of scope
- Android physical devices (#441).
device.execofdevicectlthrough a gateway (#442). Asimlock devicectlcommand: there is none.- Recording or reclaiming App Store and TestFlight apps: never (feature decision; #438's docs say so).
- The physical lease environment (
SIMLOCK_DEVICE_UDID): #438 set it. - Unlocking a device, fixing trust or pairing, and enabling Developer Mode.
- A
doctorfinding for a missingdevicectl. - File upload to a worker.
- Adding commands to the allowed list beyond the nine above (a later change, when an agent needs one).
Depends on
- #435
- #439
Approval
- Approved for delivery
Written by an agent.
- Dominant language
- TypeScript
- Stars
- 19
- Forks
- 1
- Avg merge
- 5h 56m
- Merged PRs (30d)
- 154
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from callstackincubator/simlock
-
Lint does not enforce index.ts boundaries for cleanup/, routing/, warm-pool/ and contract/ helpersOpenbug:new
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
callstackincubator/simlock#445 ·
Maintainers usually reply within 1 day
-
bug:new
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
callstackincubator/simlock#424 ·
Maintainers usually reply within 1 day
-
daemon-lifecycle e2e: the it.fails status test passes for the wrong reasonPossibly taken @V3RON claimed this 4 days ago. Openbug:triage
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
callstackincubator/simlock#422 · 1 comment · 1 assignee ·
Maintainers usually reply within 1 day
-
request:new
Difficulty 5/5 Over a week Newbie friendliness 35/100
callstackincubator/simlock#483 ·
Maintainers usually reply within 1 day
-
task:draft
Difficulty 5/5 Over a week Newbie friendliness 25/100
callstackincubator/simlock#477 · 3 comments ·
Maintainers usually reply within 1 day
All issues in callstackincubator/simlock
Similar issues
-
./collections should export setUmbracoVersion and setAllowFilePathUploads for in-process consumersOpen
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
umbraco/Umbraco-CMS-MCP-Dev#563 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
afk-ok area:data-quality importer size:S
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
enorm-labs/event-junkie#3166 ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 1/5 Under an hour Newbie friendliness 80/100
alunduil/projects-v2-sync#21 ·