Repository navigation
device.exec fills in the lease's device and refuses another #958
Description
Part of #951.
Scope
After this PR, every device.exec caller (HTTP POST /v1/leases/{id}/exec, a gateway relay, the programmatic client's exec, and simlock --gateway simctl / simlock --gateway adb, F14) may leave the device out of a simctl command on a simulator lease, an adb command on an emulator lease, or a devicectl command on an iPhone lease: the worker's driver fills in the lease's device (ADR 0047 §1). A command that names a different device is refused before it runs (ADR 0047 §2). simlock simctl / simlock adb on a worker are unchanged (ADR 0047 §5).
In short
A remote agent sends {"tool":"simctl","args":["install","/builds/App.app"]} and the app lands on its own simulator; booted or another UDID is refused with the message in the feature's Shared values ("Other-device refusal").
flowchart LR
C[device.exec caller] --> W[worker dispatcher]
W --> D["driver passthrough (CHANGED: fills in / refuses)"]
D --> P[process runner]
Before and after, HTTP on a simulator lease 6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90:
POST /v1/leases/$LEASE/exec {"tool":"simctl","args":["install","/builds/App.app"]}
before: event: exit {"exitCode":…} (simctl usage error: no device)
after: runs `xcrun simctl --set <root> install 6F1D2A8C-… /builds/App.app`
POST /v1/leases/$LEASE/exec {"tool":"simctl","args":["install","booted","/builds/App.app"]}
before: runs on whichever simulator simctl picks
after: 422 {"error":{"code":"PASSTHROUGH_REFUSED","tool":"simctl","message":"Refusing `simctl install booted /builds/App.app`: it names device booted, and this lease holds 6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90. Leave the device out; Simlock fills it in."}}
The full inputs table is the feature's Examples table. A user would notice a wrong build as a simctl or adb usage error, or a command landing on the wrong device.
Technical spec
Facts
Commit cac23e033.
| # | Fact | Source |
|---|---|---|
| F1 | #deviceExec passes the lease's device (toDriverDevice(record)) and {hasTerminal:false, platform} to passthrough; it is the only caller that passes a device |
src/daemon/dispatcher.ts:734-738; rg "\.passthrough\(" src (non-test): dispatcher.ts:662 passes undefined, driver-catalog.ts:90 and android/index.ts:691 forward, leasing/testing.ts:164 test wiring |
| F2 | DriverDevice.deviceId is the driver device id (iOS: the UDID), address is device.address ?? "" |
src/core/driver.ts:66-75; src/drivers/ios/index.ts:745 (deviceId: udid) |
| F3 | An emulator's address is its adb serial emulator-<console port> |
src/drivers/android/index.ts:948,954 (address: running.serial), :2453-2455 (serialFor) |
| F4 | iOS simctl passthrough returns ["simctl","--set",root,...args]; refuses a physical device; #subcommand refuses any flag before the verb |
src/drivers/ios/index.ts:1317-1340, :1405-1426 |
| F5 | iOS refused verbs: create, erase, delete, shutdown all, runtime delete |
src/drivers/ios/index.ts:134, :1372-1400 |
| F6 | devicectlExecCommand adds --device <udid> after the first three words unless an option -d/--device names one, unchecked against the lease |
src/drivers/ios/devicectl-exec.ts:168, :205-211 |
| F7 | Android virtual passthrough returns ["-P", port, ...args], no -s; walkGlobals allows only -s <v>, -t <v>, -t<n>, -d, -e before the subcommand |
src/drivers/android/index.ts:684-698, :347-391 |
| F8 | Android physical passthrough already inserts -s <serial> and refuses any leading flag |
src/drivers/android/physical-handler.ts:234, :361-368 |
| F9 | simctl usage, Xcode 27.0 (27A266a): device first operand for addmedia, appinfo, boot, get_app_container, getenv, icloud_sync, install, install_app_data, io, keychain, listapps, location, openurl, privacy, push, reboot, rename, shutdown, status_bar, terminate, ui, uninstall, upgrade; options then device for launch [-w] [-a <arch>|--arch=<arch>] [--console|--console-pty] [--stdout=] [--stderr=] [--terminate-running-process] [--checked-allocations] <device>, spawn [-w] [-s] [-a <arch>|--arch=] [--checked-allocations] <device>, pbcopy [-v] <device>, pbpaste [-v] <device>; device accepts a UDID or booted |
xcrun simctl help and xcrun simctl help launch / spawn on this machine |
| F10 | adb help (platform-tools 37.0.0): commands with no device include devices, help, version, connect, disconnect, pair, forward --list, mdns, keygen, start-server, kill-server, reconnect offline; reconnect alone and reverse --list act on a device; a lone --version / --help is self-contained (src/drivers/android/index.ts:344) |
adb help |
| F11 | PASSTHROUGH_REFUSED carries {tool}, HTTP 422, CLI exit 2 |
src/contract/errors.ts:100, :327 |
| F12 | A test passes a virtual device with address: "" and expects no -s; it stays true (empty address → nothing filled in) |
src/drivers/android/index.test.ts:99-104, :3273-3286 |
| F14 | `simlock simctl | adbagainst a gateway calldevice.exec` |
| F15 | devicectl accepts --device=<value> inline (splitOption); tests pin that --device OTHER, -d OTHER, --device=OTHER pass today, and option cases use -d x / --device x / --device=x |
src/drivers/ios/devicectl-exec.ts:125-130, :168; src/drivers/ios/devicectl-passthrough.test.ts:200-221, :272-281, :305-309 |
| F16 | DriverCatalog.passthrough routes by tool alone; iOS checks context.platform only for devicectl |
src/core/driver-catalog.ts:82-93; src/drivers/ios/index.ts:1357-1361 |
| F17 | HTTP error body is {"error":{"code","message","tool"}} |
src/http/errors.ts:201 |
| F13 | PassthroughContext is {hasTerminal, platform?} |
src/core/driver.ts:432-435 |
Modules touched
Searches: rg "passthrough\(" src, rg "devicectlExecCommand" src, rg -i "name your device|booted|emulator's serial|passed to the tool unchanged|-s., .-t." website docs/internal src/contract, rg -i "PASSTHROUGH_REFUSED" website.
flowchart LR
disp[daemon/dispatcher.ts] --> cat[core/driver-catalog.ts]
cat --> ios["drivers/ios/index.ts (CHANGED)"]
ios --> dctl["drivers/ios/devicectl-exec.ts (CHANGED)"]
cat --> and["drivers/android/index.ts (CHANGED)"]
src/drivers/ios/index.ts: simctl fill-in and check when a simulator device is passed; the device-verb list (ADR 0047 §1) as a constant. A UUID-shaped operand is 8-4-4-4-12 hex digits, any letter case.simctlon a lease whosecontext.platformis notiosis refused, as devicectl does (F16):Refusing `simctl`: it only serves an iOS simulator, and this lease is on the <platform> platform.src/drivers/ios/devicectl-exec.ts: refuse--device <v>,-d <v>or--device=<v>when<v>is not the lease's UDID (any letter case). The tests atdevicectl-passthrough.test.ts:272-281and the option cases at:200-221,:305-309change: they useOTHER/xas the device (F15).src/drivers/android/index.ts: emulator-sfill-in and check when a virtual device with a non-empty address is passed; the no-device list (ADR 0047 §1) as a constant; a lone--version/--helpgets nothing filled in.adbon a lease whosecontext.platformis notandroidis refused:Refusing `adb`: it only serves an Android device, and this lease is on the <platform> platform.- Comments made false:
src/contract/operations.ts:429-431,src/daemon/dispatcher.ts:706-711. - Docs:
docs/internal/flows/device-exec.md(Resolution per platform),src/drivers/ios/AGENTS.md,src/drivers/android/AGENTS.md(Passthrough),src/cli/AGENTS.md(gateway passthrough), and the website:using-devices/remote-agents.mdx("Running commands with exec": the "Name your device" and "find the emulator's serial" bullets, the example at line 122),using-devices/programmatic-client.mdx:115,gateway/index.mdx:32(install bootedexample),reference/cli.mdx:166and:181(refusals and allowed adb globals through a gateway),reference/errors.mdx:79(PASSTHROUGH_REFUSEDcauses).
A request through a gateway is unchanged in shape: ADR 0047's sequence diagram.
Contract and event changes
None. device.exec input and output are unchanged; no protocol bump (ADR 0047 §4). No event.
Other code on the same state
The state is the argument list a driver returns. Searches: rg "passthrough\(" src (5 non-test hits, F1). driver.passthrough (dispatcher.ts:662) passes no device, so nothing is filled in or refused there; the existing tests in src/drivers/ios/index.test.ts:1664 and the Android passthrough tests that pass undefined keep proving it. leasing/testing.ts:164 forwards whatever a test passes.
Failure modes
The task adds no process or network call; it changes arguments before the existing spawn.
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
driver passthrough with a device |
fills in | command runs as today | exec.timeoutMs from spawn, as today |
| same | names another device | PASSTHROUGH_REFUSED, nothing spawned |
— |
| same | emulator with empty address |
args passed unchanged, nothing checked | — |
Writes no state.
Rules in play
- ADR 0047 §1–5: the table, the refusal, every caller, no contract change, local path unchanged.
- Drivers rule 6: "the driver owns the scoping flags and the refused verbs" — the fill-in lives in the drivers.
- Architecture rule 10, "A rule is enforced in exactly one place" — no copy in the dispatcher or a frontend.
- Testing rule 7, "Prove each claim once, at the cheapest level" — the driver passthrough is the seam; no e2e.
Tests
Seam: each driver's passthrough(tool, device, args, {hasTerminal:false, platform}), modelled on src/drivers/ios/index.test.ts:1664 and src/drivers/android/index.test.ts:3273 and src/drivers/ios/devicectl-passthrough.test.ts:223. src/drivers/android/index.test.ts:3285 stays as is (its device has an empty address, F12); the devicectl tests in F15 change.
- A
device.execcaller sends a simctl device verb without a device and the command runs with the lease's UDID in place:install /b/App.app;launch --console com.x;launch -a arm64 com.x;spawn -s launchctl list→spawn -s <udid> launchctl list;io screenshot /tmp/s.png; own UDID upper or lower case → unchanged;list devices→ unchanged;runtime list→ unchanged. - A
device.execcaller's simctl command names another device and is refused with the shared message: another UUID;booted;launch --console booted com.x; andsimctlon an Android lease is refused with the platform message. - A
device.execcaller sends an emulator adb command without a device and it runs with-s <serial>first:shell getprop;install app.apk;reverse --list;-s <own>→ unchanged;devices,version,--version,forward --list,reconnect offline→ unchanged; empty address → unchanged. (forward --listandreconnect offlineadded in delivery from ADR 0047 §1, pending the maintainer.) - A
device.execcaller's emulator adb command names another device and is refused with the shared message:-s emulator-5556;-e;-d;-t 3;-t3;-t <own serial>(added in delivery from ADR 0047 §2:-tis always refused, pending the maintainer); andadbon an iOS lease is refused with the platform message. - A
device.execcaller's devicectl command names another device and is refused with the shared message; its own is left alone:--device <other>;-d <other>;--device=<other>;--device <own>and--device <OWN in upper case>→ unchanged.
Done when
pnpm checkis green (toolchain.md).- The five Tests lines pass in
pnpm test. - The website pages in Modules touched no longer tell a caller to name the device, look up the emulator serial, or use
booted, and say a command naming another device is refused. docs/internal/flows/device-exec.md, both driver manuals and the two comments in Modules touched describe the fill-in.
Out of scope
simlock simctl/simlock adbon a worker (simlock simctl and simlock adb on a worker run only against the caller's lease, and fill the device in #948).- Filling in for
simctlverbs with two devices (pair,pbsync,clone) or an optional one (logverbose). - Physical Android
adbchanges (F8 already fills in).
Depends on
- docs(adr): 0047 device.exec fills in the lease's device, and refuses another (#951) #955 (ADR 0047, bottom of the stack)
- fix(http): an Android lease over HTTP names its adb serial as address #953 (rewrites the
remote-agents.mdxserial-lookup bullet this task removes) - Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 (exports the refusal constants insrc/drivers/ios/index.ts:134-138andsrc/drivers/android/index.ts:289, and changes#assertProxyable, which this task also changes)
Written by an agent.
Activity
feat(ios): simctl fills the lease's UDID into a device verb (#958)
test: a device.exec caller's simctl command names another device and …
feat(ios): simctl refuses another device and another platform (#958)
test: a device.exec caller's emulator adb command runs with -s <seria…
feat(android): adb fills the emulator's serial into a command that na…
test: a device.exec caller's emulator adb command names another devic…
feat(android): adb refuses another device and another platform (#958)
test: a device.exec caller's devicectl command names another device a…
feat(ios): devicectl refuses a --device that is not the lease's (#958)
docs(drivers): device.exec fills in the lease's device and refuses an…
refactor(drivers): split the device checks to stay under the complexi…
docs(exec): the flow says the driver, not the daemon, fills in and ch…
fix(drivers): an emulator adb command naming its own serial with -t i…
docs(exec): -t with the lease's own serial is accepted like -s (#958)
fix(drivers): an emulator adb command refuses -t whatever it names; o…
docs(exec): own-device naming is true per lease kind; driver manuals …
docs(adr): 0047 device.exec fills in the lease's device, and refuses …
feat(drivers): device.exec fills in the lease's device and refuses an…