Skip to content

device.exec fills in the lease's device and refuses another #958

Closed

Description

@V3RON

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]
Loading

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)"]
Loading
  • 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. simctl on a lease whose context.platform is not ios is 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 at devicectl-passthrough.test.ts:272-281 and the option cases at :200-221, :305-309 change: they use OTHER / x as the device (F15).
  • src/drivers/android/index.ts: emulator -s fill-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 / --help gets nothing filled in. adb on a lease whose context.platform is not android is 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 booted example), reference/cli.mdx:166 and :181 (refusals and allowed adb globals through a gateway), reference/errors.mdx:79 (PASSTHROUGH_REFUSED causes).

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.exec caller 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.exec caller's simctl command names another device and is refused with the shared message: another UUID; booted; launch --console booted com.x; and simctl on an Android lease is refused with the platform message.
  • A device.exec caller 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 --list and reconnect offline added in delivery from ADR 0047 §1, pending the maintainer.)
  • A device.exec caller'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: -t is always refused, pending the maintainer); and adb on an iOS lease is refused with the platform message.
  • A device.exec caller'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 check is 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

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  3. self-assigned this
    on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions