From a3b9f48570ebaa62108307a8a53908090c354701 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 3 Nov 2026 08:59:28 +0100 Subject: [PATCH 1/8] feat(lease): name the iOS simulator mode in a proxy lease device spec A backend in front of the daemon (simlock) can lease an iOS simulator slim or full, but a proxy allocate could not ask for one: the device spec had no key for it, so every lease got the backend's default. Apple Pay and Wallet (PassKit) flows need a full simulator on an otherwise-slim farm. `deviceMode` joins osVersion, imageTag and physical as a device-spec key: the CLI's --device-mode, the SDK's leases.allocate and MCP send it on the allocate, and a daemon refuses it with device-spec-unsupported like the others. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 7 ++--- .../client/src/__tests__/index-leases.test.ts | 2 ++ .../src/flag-definitions-target.ts | 12 +++++++++ packages/command-registry/src/flag-groups.ts | 1 + .../command-surface/src/cli-grammar/common.ts | 2 +- packages/command-surface/src/command-flags.ts | 1 + packages/command-surface/src/command-input.ts | 5 +++- .../src/schema/option-schema.ts | 1 + .../src/__tests__/lease-scope.test.ts | 26 ++++++++++++------- packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-lease.ts | 2 ++ packages/contracts/src/lease-scope.ts | 13 ++++++---- .../contracts/src/remote-config-fields.ts | 1 + packages/contracts/src/request-envelope.ts | 1 + .../request-router-device-spec-gate.test.ts | 13 ++++++++++ packages/remote/src/remote-config-schema.ts | 1 + scripts/integration-progress-model.ts | 2 +- .../commands/connection-lease-transaction.ts | 1 + src/cli/commands/proxy-lease-device.ts | 3 ++- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/remote-proxy.md | 6 +++-- 21 files changed, 79 insertions(+), 24 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index b126b7e0c8..a53a264194 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -329,14 +329,15 @@ The host sets the tenant, session isolation, and lease of that client on every r ## Device spec -A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, and `physical` on `agent_device.lease.allocate`. +A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, `deviceMode`, and `physical` on `agent_device.lease.allocate`. Only a backend in front of the daemon, such as simlock, can pick a device by these keys. A daemon cannot pick by them. If a daemon ignored them, a request would run on a device that it did not ask for. -Thus, a daemon refuses every request that names a non-blank `osVersion` or `imageTag`, or `physical: true`, before admission and before a lease is recorded. +Thus, a daemon refuses every request that names a non-blank `osVersion`, `imageTag` or `deviceMode`, or `physical: true`, before admission and before a lease is recorded. The refusal is `UNSUPPORTED_OPERATION` with `details.reason: 'device-spec-unsupported'` and `details.keys`. This applies to a local daemon and to a daemon behind `agent-device proxy`. -A blank `osVersion` or `imageTag` and `physical: false` name nothing, so a daemon accepts them. +A blank `osVersion`, `imageTag` or `deviceMode` and `physical: false` name nothing, so a daemon accepts them. +`deviceMode` (`slim` or `full`) chooses how much of an iOS simulator the backend keeps running; the backend validates the value. A named value is used as given, without trimming. A value of another type is refused with `INVALID_ARGS`, `details.reason: 'device-spec-invalid'`, and `details.keys`. `readLeaseDeviceSpec` is the one decision on these rules, for CLI flags, the SDK, MCP, and the daemon. diff --git a/packages/client/src/__tests__/index-leases.test.ts b/packages/client/src/__tests__/index-leases.test.ts index f93091e858..46df84eb92 100644 --- a/packages/client/src/__tests__/index-leases.test.ts +++ b/packages/client/src/__tests__/index-leases.test.ts @@ -58,11 +58,13 @@ test('lease allocation sends the device spec and returns the device the backend platform: 'android', osVersion: '34', imageTag: 'google_apis', + deviceMode: 'full', physical: true, }); assert.equal(setup.calls[0]?.flags?.osVersion, '34'); assert.equal(setup.calls[0]?.flags?.imageTag, 'google_apis'); + assert.equal(setup.calls[0]?.flags?.deviceMode, 'full'); assert.equal(setup.calls[0]?.flags?.physical, true); assert.deepEqual(lease.device, { model: 'Pixel 9', diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 22c6a9320f..744cb6fb12 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -76,6 +76,18 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: false, recorded: false, }, + { + key: 'deviceMode', + names: ['--device-mode'], + type: 'string', + usageLabel: '--device-mode ', + usageDescription: + 'Proxy lease: iOS simulator mode (slim or full) the backend leases; refused without such a backend', + inputDescription: + 'iOS simulator mode for the device a proxy backend leases, slim or full, sent as given; the backend validates it. A blank string names nothing; a non-string fails with INVALID_ARGS, reason device-spec-invalid. Only the CLI allocating a lease through connect proxy sends it to that backend; a daemon refuses every other request that names it, with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, { key: 'physical', names: ['--physical'], diff --git a/packages/command-registry/src/flag-groups.ts b/packages/command-registry/src/flag-groups.ts index 8d193dd336..dfa837fd6c 100644 --- a/packages/command-registry/src/flag-groups.ts +++ b/packages/command-registry/src/flag-groups.ts @@ -38,6 +38,7 @@ export const METRO_RELOAD_FLAGS = flagKeys('metroHost', 'metroPort', 'bundleUrl' export const LEASE_DEVICE_SPEC_FLAGS = flagKeys( 'osVersion', 'imageTag', + 'deviceMode', 'physical', ) satisfies typeof LEASE_DEVICE_SPEC_KEYS; export const REPEATED_TOUCH_FLAGS = flagKeys( diff --git a/packages/command-surface/src/cli-grammar/common.ts b/packages/command-surface/src/cli-grammar/common.ts index aeedc120b1..707e205356 100644 --- a/packages/command-surface/src/cli-grammar/common.ts +++ b/packages/command-surface/src/cli-grammar/common.ts @@ -147,7 +147,7 @@ export function repeatedInputFromFlags(flags: CliFlags): Record /** The spec keys the flags name, as `requireLeaseDeviceSpec` decides them; an invalid key throws. */ export function leaseDeviceSpecInputFromFlags( flags: CliFlags, -): Pick { +): Pick { return requireLeaseDeviceSpec(flags).spec; } diff --git a/packages/command-surface/src/command-flags.ts b/packages/command-surface/src/command-flags.ts index 080b61bbb5..f58a664506 100644 --- a/packages/command-surface/src/command-flags.ts +++ b/packages/command-surface/src/command-flags.ts @@ -61,6 +61,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { androidDeviceAllowlist: options.androidDeviceAllowlist, osVersion: options.osVersion, imageTag: options.imageTag, + deviceMode: options.deviceMode, physical: options.physical, surface: options.surface, activity: options.activity, diff --git a/packages/command-surface/src/command-input.ts b/packages/command-surface/src/command-input.ts index dad83b0467..b8296f2508 100644 --- a/packages/command-surface/src/command-input.ts +++ b/packages/command-surface/src/command-input.ts @@ -432,11 +432,14 @@ export function leaseDeviceSpecFields() { return { osVersion: leaseDeviceSpecField('osVersion'), imageTag: leaseDeviceSpecField('imageTag'), + deviceMode: leaseDeviceSpecField('deviceMode'), physical: leaseDeviceSpecField('physical'), }; } -function leaseDeviceSpecField(key: TKey) { +function leaseDeviceSpecField( + key: TKey, +) { return customField( optionField(key).schema, (record) => requireLeaseDeviceSpec({ [key]: record[key] }).spec[key], diff --git a/packages/command-surface/src/schema/option-schema.ts b/packages/command-surface/src/schema/option-schema.ts index 7930a5d201..3fb03a4d5b 100644 --- a/packages/command-surface/src/schema/option-schema.ts +++ b/packages/command-surface/src/schema/option-schema.ts @@ -33,6 +33,7 @@ const ENV_EXCLUDED_FLAG_KEYS = new Set([ // The proxy lease device spec comes from a flag or `connect proxy`, never ambient environment. 'osVersion', 'imageTag', + 'deviceMode', 'physical', ]); diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index c85de05c68..59b84c7cd7 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -271,10 +271,15 @@ test('readLeaseAllocateProviderFlags carries a named or invalid spec key as give test('readLeaseDeviceSpec decides each key is named, not named, or invalid', () => { assert.deepEqual( - readLeaseDeviceSpec({ osVersion: '>=26', imageTag: 'google_apis', physical: true }), + readLeaseDeviceSpec({ + osVersion: '>=26', + imageTag: 'google_apis', + deviceMode: 'full', + physical: true, + }), { - spec: { osVersion: '>=26', imageTag: 'google_apis', physical: true }, - keys: ['osVersion', 'imageTag', 'physical'], + spec: { osVersion: '>=26', imageTag: 'google_apis', deviceMode: 'full', physical: true }, + keys: ['osVersion', 'imageTag', 'deviceMode', 'physical'], invalidKeys: [], }, ); @@ -282,14 +287,17 @@ test('readLeaseDeviceSpec decides each key is named, not named, or invalid', () assert.deepEqual(readLeaseDeviceSpec(undefined), notNamed); assert.deepEqual(readLeaseDeviceSpec({}), notNamed); assert.deepEqual( - readLeaseDeviceSpec({ osVersion: '', imageTag: ' ', physical: false }), + readLeaseDeviceSpec({ osVersion: '', imageTag: ' ', deviceMode: '', physical: false }), notNamed, ); - assert.deepEqual(readLeaseDeviceSpec({ osVersion: 26, imageTag: null, physical: 'yes' }), { - spec: {}, - keys: [], - invalidKeys: ['osVersion', 'imageTag', 'physical'], - }); + assert.deepEqual( + readLeaseDeviceSpec({ osVersion: 26, imageTag: null, deviceMode: true, physical: 'yes' }), + { + spec: {}, + keys: [], + invalidKeys: ['osVersion', 'imageTag', 'deviceMode', 'physical'], + }, + ); assert.deepEqual(readLeaseDeviceSpec({ osVersion: '18', physical: 1 }), { spec: { osVersion: '18' }, keys: ['osVersion'], diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index ffb6ea69e0..8ff0d4b9a7 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -65,6 +65,7 @@ export type CliFlags = CloudProviderProfileFields & // that names any of them, as `readLeaseDeviceSpec` decides. osVersion?: string; imageTag?: string; + deviceMode?: string; physical?: boolean; stale?: boolean; iosSimulatorDeviceSet?: string; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 09ef626b60..a32aba715a 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -43,6 +43,8 @@ export type LeaseDeviceSpec = { osVersion?: string; /** Android system image tag, for example `google_apis_playstore`. */ imageTag?: string; + /** iOS simulator mode, `slim` or `full`; the backend validates it. */ + deviceMode?: string; /** `true` asks for a physical device instead of a simulator or emulator. */ physical?: boolean; }; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 3f6e2b9966..4426382572 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -276,6 +276,7 @@ type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[n export const LEASE_DEVICE_SPEC_KEYS = [ 'osVersion', 'imageTag', + 'deviceMode', 'physical', ] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[]; @@ -330,12 +331,14 @@ export type LeaseDeviceSpecReading = { /** * The one decision on what a device spec names, for the CLI, the SDK, MCP and the daemon alike. - * Each key is named, not named, or invalid: a non-blank `osVersion` or `imageTag` and + * Each key is named, not named, or invalid: a non-blank `osVersion`, `imageTag` or `deviceMode` and * `physical: true` are named, with the value as given; an absent key, a blank string and * `physical: false` name nothing; any other type is invalid. */ export function readLeaseDeviceSpec( - source: { osVersion?: unknown; imageTag?: unknown; physical?: unknown } | undefined, + source: + | { osVersion?: unknown; imageTag?: unknown; deviceMode?: unknown; physical?: unknown } + | undefined, ): LeaseDeviceSpecReading { const spec: Record = {}; const invalidKeys: LeaseDeviceSpecKey[] = []; @@ -378,14 +381,14 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined { /** The refusal for a request whose device spec nothing on its path can honour. */ export function deviceSpecUnsupportedError( keys: readonly string[], - message = 'This daemon cannot pick a device by OS version, image tag, or physical/virtual class.', + message = 'This daemon cannot pick a device by OS version, image tag, device mode, or physical/virtual class.', details: Record = {}, ): AppError { return new AppError('UNSUPPORTED_OPERATION', message, { ...details, reason: DEVICE_SPEC_UNSUPPORTED_REASON, keys: [...keys], - hint: 'Drop --os, --image-tag and --physical, or connect through a proxy whose backend picks devices by spec.', + hint: 'Drop --os, --image-tag, --device-mode and --physical, or connect through a proxy whose backend picks devices by spec.', }); } @@ -393,7 +396,7 @@ export function deviceSpecUnsupportedError( export function deviceSpecInvalidError(keys: readonly string[]): AppError { return new AppError( 'INVALID_ARGS', - 'osVersion and imageTag take a string, and physical takes a boolean.', + 'osVersion, imageTag and deviceMode take a string, and physical takes a boolean.', { reason: DEVICE_SPEC_INVALID_REASON, keys: [...keys] }, ); } diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 8e0e0065ab..4b214d9839 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -151,6 +151,7 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & serial?: string; osVersion?: string; imageTag?: string; + deviceMode?: string; physical?: boolean; iosSimulatorDeviceSet?: string; androidDeviceAllowlist?: string; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index 3f15542118..a2ff4ffbbd 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -70,6 +70,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & surface?: SessionSurface; osVersion?: string; imageTag?: string; + deviceMode?: string; physical?: boolean; activity?: string; launchConsole?: string; diff --git a/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts b/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts index 17f9d9f08f..28b4669b1f 100644 --- a/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts @@ -76,6 +76,19 @@ test('a command naming a spec is refused with device-spec-unsupported before it expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled(); }); +test('a command naming a device mode is refused with device-spec-unsupported before it runs', async () => { + const response = await sendAppSwitcher({ deviceMode: 'full' }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'device-spec-unsupported', keys: ['deviceMode'] }, + }, + }); + expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled(); +}); + test('a command giving a spec key the wrong type is refused with device-spec-invalid before it runs', async () => { // The named osVersion would be refused as unsupported; the invalid key is reported instead. const response = await sendAppSwitcher({ diff --git a/packages/remote/src/remote-config-schema.ts b/packages/remote/src/remote-config-schema.ts index 7189b3a0a0..6f12cf86d5 100644 --- a/packages/remote/src/remote-config-schema.ts +++ b/packages/remote/src/remote-config-schema.ts @@ -50,6 +50,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ // The device spec only a proxy backend honours; no environment variable sets it. { key: 'osVersion', type: 'string', env: false }, { key: 'imageTag', type: 'string', env: false }, + { key: 'deviceMode', type: 'string', env: false }, { key: 'physical', type: 'boolean', env: false }, { key: 'iosSimulatorDeviceSet', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 7bc99ffd24..2cb9b2f827 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -255,7 +255,7 @@ function summarizeProviderScenarioFlagExclusions() { // The device spec is honoured by a backend in front of the daemon; the daemon only refuses it. name: 'proxy lease device spec', owner: 'proxy allocate-by-spec, request-router refusal, and lease-scope tests', - keys: ['osVersion', 'imageTag', 'physical'], + keys: ['osVersion', 'imageTag', 'deviceMode', 'physical'], }, { name: 'cloud artifact provider lookup', diff --git a/src/cli/commands/connection-lease-transaction.ts b/src/cli/commands/connection-lease-transaction.ts index 9a43e09a05..4aa3e5bd99 100644 --- a/src/cli/commands/connection-lease-transaction.ts +++ b/src/cli/commands/connection-lease-transaction.ts @@ -379,6 +379,7 @@ async function allocateOrReuseLease( const { osVersion: _osVersion, imageTag: _imageTag, + deviceMode: _deviceMode, physical: _physical, ...providerFlags } = readLeaseAllocateProviderFlags(flags); diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index b677491509..2956bb773d 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -63,7 +63,7 @@ export async function resolveProxyLeaseState(options: { if (!options.leaseBackend) { throw new AppError( 'INVALID_ARGS', - `${options.command} with --os, --image-tag or --physical requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, + `${options.command} with --os, --image-tag, --device-mode or --physical requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, ); } return { state: options.state, allocateBySpec: true }; @@ -140,6 +140,7 @@ export async function bindLeaseNamedDevice(options: { applyDeviceScopeSelector(flags, leased); delete flags.osVersion; delete flags.imageTag; + delete flags.deviceMode; delete flags.physical; return { state: { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 47b34af2b6..4821689d1a 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -570,7 +570,7 @@ Direct proxy flow for a remote Mac/simulator: Leased sessions: a connection from connect proxy, connect (cloud), another connect provider, or a remote config that sets leaseProvider leases its device for one agent. Every command on it, from connect to disconnect, names its session with --session , AGENT_DEVICE_SESSION, or a config session. Without one, it fails with INVALID_ARGS, reason REMOTE_SESSION_REQUIRED, before anything is leased, and never falls back to the connection another agent on the machine saved last. Set AGENT_DEVICE_SESSION once per agent or CI job before connect. Proxy lease by device spec: - Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , and --physical add a device spec to that request. A non-blank osVersion or imageTag and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. + Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical); open --json prints it. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 4f7819c217..076dc6914c 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -60,25 +60,27 @@ config, an explicit `--config` file, or protected CI environment variables. ## Lease by device spec -A service in front of the daemon, such as simlock, can also pick the device by OS version, Android image tag, and physical or virtual class. Pass `--os `, `--image-tag `, or `--physical` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. A non-blank `osVersion` or `imageTag` and `physical: true` name a spec key and are sent as given. An empty or blank string and `physical: false` name nothing, from the CLI, the SDK, or MCP. A value of another type (for example `physical: "yes"`) fails with `INVALID_ARGS` and `details.reason: "device-spec-invalid"`. The service validates the values themselves (for example, Android `--os` is an API level). `connect proxy` stores the spec keys it names as defaults like `--platform` and `--device`. `--target` stays the device class selector. +A service in front of the daemon, such as simlock, can also pick the device by OS version, Android image tag, iOS simulator mode, and physical or virtual class. Pass `--os `, `--image-tag `, `--device-mode `, or `--physical` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. A non-blank `osVersion`, `imageTag` or `deviceMode` and `physical: true` name a spec key and are sent as given. An empty or blank string and `physical: false` name nothing, from the CLI, the SDK, or MCP. A value of another type (for example `physical: "yes"`) fails with `INVALID_ARGS` and `details.reason: "device-spec-invalid"`. The service validates the values themselves (for example, Android `--os` is an API level). `connect proxy` stores the spec keys it names as defaults like `--platform` and `--device`. `--target` stays the device class selector. ```bash agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore agent-device open MyApp --platform ios --physical --os 18.6.2 +agent-device open MyApp --platform ios --device-mode full ``` | Flag | Key on `agent_device.lease.allocate` | Type | | --- | --- | --- | | `--os ` | `osVersion` | string | | `--image-tag ` | `imageTag` | string | +| `--device-mode ` | `deviceMode` | string | | `--physical` | `physical` | `true` | With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. -The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the three keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. +The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. ## Download captures From 30454ea69a893101e5757730e887895382c74600 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 07:39:21 +0100 Subject: [PATCH 2/8] test(lease): cover the CLI and MCP half of the device-mode route The open --device-mode route had no test: deleting the spec key from the materialized flags, the non-by-spec allocate or the connect proxy profile left the suite green. Extend the open-by-spec, re-allocate, profile and MCP cases and iterate LEASE_DEVICE_SPEC_KEYS. The two hand-written key lists in the CLI become loops over the same constant, so the next key needs no edit there. lease.device does not report deviceMode; the docs now say so. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 1 + .../__tests__/proxy-lease-device-spec.test.ts | 28 ++++++++++--------- .../commands/connection-lease-transaction.ts | 10 ++----- src/cli/commands/proxy-lease-device.ts | 6 ++-- src/commands/schema/cli-help.ts | 2 +- .../command-tools-operator-inputs.test.ts | 3 +- website/docs/docs/remote-proxy.md | 2 +- 7 files changed, 25 insertions(+), 27 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index a53a264194..89d1af7c52 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -344,6 +344,7 @@ A value of another type is refused with `INVALID_ARGS`, `details.reason: 'device A remote config file first applies the value rules of every config key: a string must be non-empty, and a boolean also accepts a boolean string such as `"true"`. The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `physical`). `deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood. +`lease.device` does not report `deviceMode`, so the proof does not confirm it: a backend that accepts the key must honour it. A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. `details.released` reports whether the release succeeded. diff --git a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index a9d3f7f6f7..1c069f191f 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -6,7 +6,8 @@ import { import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import type { LeaseDevice } from '@agent-device/contracts/client'; +import type { LeaseDevice, LeaseDeviceSpec } from '@agent-device/contracts/client'; +import { LEASE_DEVICE_SPEC_KEYS } from '@agent-device/contracts/lease-scope'; import { AppError } from '@agent-device/kernel/errors'; import { connectionWorkspace, @@ -63,11 +64,12 @@ function renewingClient(heartbeats: string[]) { test('proxy open by device spec sends the spec on allocate without listing devices and reports the granted device', async () => { const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-'); const requests: LeaseAllocateRequest[] = []; + const spec: LeaseDeviceSpec = { osVersion: ' >=26 ', deviceMode: 'full', physical: true }; const materialized = await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'open', - flags: { ...flags, device: 'iPhone 17 Pro', osVersion: ' >=26 ', physical: true }, + flags: { ...flags, device: 'iPhone 17 Pro', ...spec }, client: createTestClient({ listDevices: unexpectedCommandCall, allocate: async (request) => { @@ -82,19 +84,18 @@ test('proxy open by device spec sends the spec on allocate without listing devic assert.equal(requests[0]?.deviceKey, undefined); assert.equal(requests[0]?.platform, 'ios'); assert.equal(requests[0]?.device, 'iPhone 17 Pro'); - assert.equal(requests[0]?.osVersion, ' >=26 '); - assert.equal(requests[0]?.physical, true); - assert.equal(requests[0]?.imageTag, undefined); assert.deepEqual(materialized.leaseDevice, GRANTED_DEVICE); // The leased device is named by its identity from here on; the spec does not reach the command. assert.equal(materialized.flags.udid, 'SIM-REMOTE'); - assert.equal(materialized.flags.osVersion, undefined); - assert.equal(materialized.flags.physical, undefined); + for (const key of LEASE_DEVICE_SPEC_KEYS) { + assert.equal(requests[0]?.[key], spec[key]); + assert.equal(materialized.flags[key], undefined); + } const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); assert.equal(state?.leaseId, 'lease-1'); assert.deepEqual(state?.leaseDeviceSpec, { leaseId: 'lease-1', - spec: { osVersion: ' >=26 ', physical: true }, + spec, device: GRANTED_DEVICE, }); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -479,7 +480,7 @@ test('a lease re-allocated by its stored device for a non-allocating command doe await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'snapshot', - flags: { ...flags, osVersion: '>=26' }, + flags: { ...flags, osVersion: '>=26', deviceMode: 'full' }, client: createTestClient({ listDevices: unexpectedCommandCall, heartbeat: async () => { @@ -494,7 +495,7 @@ test('a lease re-allocated by its stored device for a non-allocating command doe assert.equal(requests.length, 1); assert.equal(requests[0]?.deviceKey, 'ios:mobile:SIM-REMOTE'); - assert.equal(requests[0]?.osVersion, undefined); + for (const key of LEASE_DEVICE_SPEC_KEYS) assert.equal(requests[0]?.[key], undefined); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -542,6 +543,7 @@ test('connect proxy stores the device spec as profile defaults like platform and platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', + deviceMode: 'full', physical: true, }, client: createTestClient(), @@ -557,6 +559,7 @@ test('connect proxy stores the device spec as profile defaults like platform and assert.equal(generated.platform, 'android'); assert.equal(generated.osVersion, '34'); assert.equal(generated.imageTag, 'google_apis_playstore'); + assert.equal(generated.deviceMode, 'full'); assert.equal(generated.physical, true); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -576,6 +579,7 @@ test('connect proxy stores no default for a spec key that names nothing', async platform: 'android', osVersion: '', imageTag: ' ', + deviceMode: '', physical: false, }, client: createTestClient(), @@ -589,8 +593,6 @@ test('connect proxy stores no default for a spec key that names nothing', async unknown >; assert.equal(generated.platform, 'android'); - assert.equal('osVersion' in generated, false); - assert.equal('imageTag' in generated, false); - assert.equal('physical' in generated, false); + for (const key of LEASE_DEVICE_SPEC_KEYS) assert.equal(key in generated, false); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection-lease-transaction.ts b/src/cli/commands/connection-lease-transaction.ts index 4aa3e5bd99..322524aea9 100644 --- a/src/cli/commands/connection-lease-transaction.ts +++ b/src/cli/commands/connection-lease-transaction.ts @@ -1,6 +1,7 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { isInactiveLeaseError, + LEASE_DEVICE_SPEC_KEYS, readLeaseAllocateProviderFlags, requireLeaseDeviceSpec, } from '@agent-device/contracts/lease-scope'; @@ -376,13 +377,8 @@ async function allocateOrReuseLease( }); } // The spec rides only a by-spec allocate, so it is added below rather than taken from the flags. - const { - osVersion: _osVersion, - imageTag: _imageTag, - deviceMode: _deviceMode, - physical: _physical, - ...providerFlags - } = readLeaseAllocateProviderFlags(flags); + const providerFlags = readLeaseAllocateProviderFlags(flags); + for (const key of LEASE_DEVICE_SPEC_KEYS) delete providerFlags[key]; const lease = await client.leases.allocate({ tenant: state.tenant, runId: state.runId, diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 2956bb773d..31db4787c2 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -14,6 +14,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { deviceSpecUnsupportedError, + LEASE_DEVICE_SPEC_KEYS, requireLeaseDeviceSpec, } from '@agent-device/contracts/lease-scope'; import type { Lease, LeaseDevice, LeaseDeviceSpec } from '@agent-device/contracts/client'; @@ -138,10 +139,7 @@ export async function bindLeaseNamedDevice(options: { await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); } applyDeviceScopeSelector(flags, leased); - delete flags.osVersion; - delete flags.imageTag; - delete flags.deviceMode; - delete flags.physical; + for (const key of LEASE_DEVICE_SPEC_KEYS) delete flags[key]; return { state: { ...options.state, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 4821689d1a..f66edbc040 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -573,7 +573,7 @@ Proxy lease by device spec: Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore - A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical); open --json prints it. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. + A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical); open --json prints it. lease.device does not report deviceMode, so --device-mode needs a backend that supports the key. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. Human takeover of a leased remote device: Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported. diff --git a/src/mcp/__tests__/command-tools-operator-inputs.test.ts b/src/mcp/__tests__/command-tools-operator-inputs.test.ts index 233b544312..075c163d00 100644 --- a/src/mcp/__tests__/command-tools-operator-inputs.test.ts +++ b/src/mcp/__tests__/command-tools-operator-inputs.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import type { AgentDeviceClient } from '@agent-device/command-surface'; +import { LEASE_DEVICE_SPEC_KEYS } from '@agent-device/contracts/lease-scope'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { createCommandToolExecutor, listCommandTools } from '../command-tools.ts'; @@ -265,7 +266,7 @@ test('MCP open, install, reinstall and uninstall advertise the proxy lease devic const tool = listCommandTools().find((candidate) => candidate.name === name); assert.ok(tool, `expected an MCP tool named ${name}`); const properties = tool.inputSchema.properties ?? {}; - for (const key of ['osVersion', 'imageTag', 'physical']) { + for (const key of LEASE_DEVICE_SPEC_KEYS) { assert.equal(key in properties, true, `${name} does not advertise ${key}`); } } diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 076dc6914c..1b8005284a 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -78,7 +78,7 @@ agent-device open MyApp --platform ios --device-mode full With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. -A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. +A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. It does not report the simulator mode, so `--device-mode` needs a service that supports the key: one that ignores it leases its default mode without an error. The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. From bb602c0002854cf0ae20413b68841751eb3e7cd4 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 07:52:21 +0100 Subject: [PATCH 3/8] fix(lease): refuse a grant that reports another device mode than requested lease.device did not report deviceMode, so a backend could lease a slim simulator for a full request with no error. The lease can now report the granted mode, and the CLI proxy connection releases a new lease that reports another mode than the request named, and fails with device-spec-unsupported. A lease that reports no mode is still accepted, because no simlock reply reports one yet and refusing it would break valid full leases. The docs state that the mode is then unconfirmed. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 6 +- .../client/src/__tests__/index-leases.test.ts | 9 ++- packages/contracts/src/client-lease.ts | 2 + packages/contracts/src/lease-scope.ts | 2 + .../__tests__/proxy-lease-device-spec.test.ts | 77 +++++++++++++++++-- src/cli/commands/proxy-lease-device.ts | 13 +++- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/remote-proxy.md | 2 +- 8 files changed, 100 insertions(+), 13 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 89d1af7c52..c5616191e1 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -342,9 +342,11 @@ A named value is used as given, without trimming. A value of another type is refused with `INVALID_ARGS`, `details.reason: 'device-spec-invalid'`, and `details.keys`. `readLeaseDeviceSpec` is the one decision on these rules, for CLI flags, the SDK, MCP, and the daemon. A remote config file first applies the value rules of every config key: a string must be non-empty, and a boolean also accepts a boolean string such as `"true"`. -The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `physical`). +The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `deviceMode?`, `physical`). `deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood. -`lease.device` does not report `deviceMode`, so the proof does not confirm it: a backend that accepts the key must honour it. +A backend can report the granted mode as `lease.device.deviceMode`. +The CLI proxy connection treats a new lease that reports another mode than the request named like a lease without `lease.device`. +A lease that reports no mode is accepted, so the proof does not confirm `deviceMode`: a backend that ignores the key and reports no mode grants its default without an error. A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. `details.released` reports whether the release succeeded. diff --git a/packages/client/src/__tests__/index-leases.test.ts b/packages/client/src/__tests__/index-leases.test.ts index 46df84eb92..fcc3a90240 100644 --- a/packages/client/src/__tests__/index-leases.test.ts +++ b/packages/client/src/__tests__/index-leases.test.ts @@ -44,7 +44,13 @@ test('lease allocation sends the device spec and returns the device the backend runId: req.meta?.runId, backend: req.meta?.leaseBackend, deviceKey: 'android:mobile:emulator-5554', - device: { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }, + device: { + model: 'Pixel 9', + osVersion: '34', + imageTag: 'google_apis', + deviceMode: 'full', + physical: false, + }, }, }, })); @@ -70,6 +76,7 @@ test('lease allocation sends the device spec and returns the device the backend model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', + deviceMode: 'full', physical: false, }); }); diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index a32aba715a..49170a007c 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -55,6 +55,8 @@ export type LeaseDevice = { /** The exact OS version granted. */ osVersion: string; imageTag?: string; + /** The iOS simulator mode granted, when the backend reports it. */ + deviceMode?: string; physical: boolean; }; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 4426382572..2e71dc5fca 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -370,10 +370,12 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined { const osVersion = nonEmptyString(record.osVersion); if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined; const imageTag = nonEmptyString(record.imageTag); + const deviceMode = nonEmptyString(record.deviceMode); return { model, osVersion, ...(imageTag ? { imageTag } : {}), + ...(deviceMode ? { deviceMode } : {}), physical: record.physical, }; } diff --git a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index 1c069f191f..d66d8214f8 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -64,7 +64,13 @@ function renewingClient(heartbeats: string[]) { test('proxy open by device spec sends the spec on allocate without listing devices and reports the granted device', async () => { const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-'); const requests: LeaseAllocateRequest[] = []; - const spec: LeaseDeviceSpec = { osVersion: ' >=26 ', deviceMode: 'full', physical: true }; + const spec: Required = { + osVersion: ' >=26 ', + imageTag: 'google_apis', + deviceMode: 'full', + physical: true, + }; + const grantedDevice: LeaseDevice = { ...GRANTED_DEVICE, deviceMode: 'full' }; const materialized = await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), @@ -74,7 +80,7 @@ test('proxy open by device spec sends the spec on allocate without listing devic listDevices: unexpectedCommandCall, allocate: async (request) => { requests.push(request); - return grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device: GRANTED_DEVICE }); + return grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device: grantedDevice }); }, release: unexpectedCommandCall, }), @@ -84,7 +90,7 @@ test('proxy open by device spec sends the spec on allocate without listing devic assert.equal(requests[0]?.deviceKey, undefined); assert.equal(requests[0]?.platform, 'ios'); assert.equal(requests[0]?.device, 'iPhone 17 Pro'); - assert.deepEqual(materialized.leaseDevice, GRANTED_DEVICE); + assert.deepEqual(materialized.leaseDevice, grantedDevice); // The leased device is named by its identity from here on; the spec does not reach the command. assert.equal(materialized.flags.udid, 'SIM-REMOTE'); for (const key of LEASE_DEVICE_SPEC_KEYS) { @@ -96,8 +102,63 @@ test('proxy open by device spec sends the spec on allocate without listing devic assert.deepEqual(state?.leaseDeviceSpec, { leaseId: 'lease-1', spec, - device: GRANTED_DEVICE, + device: grantedDevice, + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a grant echoing another device mode than the request names is released and refused', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-spec-wrong-mode-'); + const releases: LeaseReleaseRequest[] = []; + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, deviceMode: 'full' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => + grantedLease(request, { + created: true, + deviceKey: REMOTE_DEVICE_KEY, + device: { ...GRANTED_DEVICE, deviceMode: 'slim' }, + }), + release: async (request) => { + releases.push(request); + return { released: true }; + }, + }), + }), + (error: AppError) => + error.code === 'UNSUPPORTED_OPERATION' && + error.details?.reason === 'device-spec-unsupported' && + JSON.stringify(error.details?.keys) === '["deviceMode"]', + ); + assert.deepEqual( + releases.map((request) => request.leaseId), + ['lease-1'], + ); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a grant that reports no device mode serves a request naming one', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-spec-no-mode-echo-'); + + const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, deviceMode: 'full' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => + grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device: GRANTED_DEVICE }), + release: unexpectedCommandCall, + }), }); + + assert.equal(materialized.flags.udid, 'SIM-REMOTE'); + assert.deepEqual(materialized.leaseDevice, GRANTED_DEVICE); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -480,7 +541,13 @@ test('a lease re-allocated by its stored device for a non-allocating command doe await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'snapshot', - flags: { ...flags, osVersion: '>=26', deviceMode: 'full' }, + flags: { + ...flags, + osVersion: '>=26', + imageTag: 'google_apis', + deviceMode: 'full', + physical: true, + }, client: createTestClient({ listDevices: unexpectedCommandCall, heartbeat: async () => { diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 31db4787c2..afc96e3a73 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -110,8 +110,9 @@ async function resolveProxyLeaseStateLocally(options: { * none was supplied from outside, so it is refused and never released. * * A request that names a device spec has no such fallback: a new lease that does not echo the - * granted device in `lease.device` came from a backend that ignored the spec, so it is released and - * the request fails with `device-spec-unsupported`. A renewed lease serves the request only when + * granted device in `lease.device`, or that reports another `deviceMode` than the request named, + * did not honour the spec, so it is released and the request fails with `device-spec-unsupported`. + * A lease that reports no mode is accepted: the mode is then unconfirmed. A renewed lease serves the request only when * every spec key the request names equals the one the lease was allocated by. Once a lease is bound, * the spec leaves the command flags: the device it chose is named by its identity instead. */ @@ -128,7 +129,7 @@ export async function bindLeaseNamedDevice(options: { const { lease, acquired } = materialized; const { spec, keys: specKeys } = requireLeaseDeviceSpec(flags); const leased = parseConnectionDeviceKey(lease.deviceKey); - if (acquired && specKeys.length > 0 && (!lease.device || !leased)) { + if (acquired && specKeys.length > 0 && (!leased || !leaseHonoursSpec(lease, spec))) { throw await refuseUnhonouredSpecLease(leaseLedger, options.state, lease, specKeys); } if (leased) { @@ -164,6 +165,12 @@ export async function bindLeaseNamedDevice(options: { } /** A refused allocation is discarded unless the daemon explicitly reported reuse. */ +function leaseHonoursSpec(lease: Lease, spec: LeaseDeviceSpec): boolean { + if (!lease.device) return false; + const granted = lease.device.deviceMode; + return granted === undefined || spec.deviceMode === undefined || granted === spec.deviceMode; +} + async function refuseUnhonouredSpecLease( leaseLedger: LeaseLedger, state: RemoteConnectionState, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index f66edbc040..55217ff6e6 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -573,7 +573,7 @@ Proxy lease by device spec: Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore - A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical); open --json prints it. lease.device does not report deviceMode, so --device-mode needs a backend that supports the key. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. + A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with another deviceMode than the request named, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. Human takeover of a leased remote device: Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 1b8005284a..9292299070 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -78,7 +78,7 @@ agent-device open MyApp --platform ios --device-mode full With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. -A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. It does not report the simulator mode, so `--device-mode` needs a service that supports the key: one that ignores it leases its default mode without an error. +A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports another mode than `--device-mode` asked for is released, and the command fails with reason `device-spec-unsupported`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. From 80b990b40b63b90b698333d284d5730b4b2f9bd3 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 09:40:38 +0100 Subject: [PATCH 4/8] docs(lease): say a refused grant that reports reuse is not released Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index c5616191e1..a6995c9580 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -350,6 +350,7 @@ A lease that reports no mode is accepted, so the proof does not confirm `deviceM A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. `details.released` reports whether the release succeeded. +An allocation that explicitly reports reuse (`created: false`) is refused without a release. A live lease serves a later command only when every spec key that the command names equals the one the lease was allocated by; otherwise the command fails with `CONNECTION_DEVICE_CONFLICT`. The client never picks a device by spec from the daemon inventory. `--target` stays the only class selector. From 12580a6aab780ee3992ce03c9ffcaf569e6caefe Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 09:58:26 +0100 Subject: [PATCH 5/8] fix(lease): refuse only a slim grant for a full request Comparing the modes for equality would release a valid lease once a backend reports the mode: simlock's ADR 0007 makes full a guarantee and slim best effort, so a slim request can be granted a full simulator and that is never an error. Only slim granted for full breaks the request. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 3 ++- .../__tests__/proxy-lease-device-spec.test.ts | 22 ++++++++++++++++++- src/cli/commands/proxy-lease-device.ts | 6 ++--- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/remote-proxy.md | 2 +- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index a6995c9580..2555f770ae 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -345,7 +345,8 @@ A remote config file first applies the value rules of every config key: a string The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `deviceMode?`, `physical`). `deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood. A backend can report the granted mode as `lease.device.deviceMode`. -The CLI proxy connection treats a new lease that reports another mode than the request named like a lease without `lease.device`. +The CLI proxy connection treats a new lease that reports `slim` for a `full` request like a lease without `lease.device`. +Full is a guarantee and slim is best effort, so `full` granted for a `slim` request is served. A lease that reports no mode is accepted, so the proof does not confirm `deviceMode`: a backend that ignores the key and reports no mode grants its default without an error. A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. diff --git a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index d66d8214f8..0b120eca98 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -107,7 +107,7 @@ test('proxy open by device spec sends the spec on allocate without listing devic fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('a grant echoing another device mode than the request names is released and refused', async () => { +test('a slim grant for a full request is released and refused', async () => { const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-spec-wrong-mode-'); const releases: LeaseReleaseRequest[] = []; await assert.rejects( @@ -142,6 +142,26 @@ test('a grant echoing another device mode than the request names is released and fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('a full grant serves a slim request, because slim is best effort', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-spec-slim-full-'); + const device: LeaseDevice = { ...GRANTED_DEVICE, deviceMode: 'full' }; + + const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, deviceMode: 'slim' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device }), + release: unexpectedCommandCall, + }), + }); + + assert.equal(materialized.flags.udid, 'SIM-REMOTE'); + assert.deepEqual(materialized.leaseDevice, device); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('a grant that reports no device mode serves a request naming one', async () => { const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-spec-no-mode-echo-'); diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index afc96e3a73..a403945960 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -110,7 +110,7 @@ async function resolveProxyLeaseStateLocally(options: { * none was supplied from outside, so it is refused and never released. * * A request that names a device spec has no such fallback: a new lease that does not echo the - * granted device in `lease.device`, or that reports another `deviceMode` than the request named, + * granted device in `lease.device`, or that reports a slim simulator for a `full` request, * did not honour the spec, so it is released and the request fails with `device-spec-unsupported`. * A lease that reports no mode is accepted: the mode is then unconfirmed. A renewed lease serves the request only when * every spec key the request names equals the one the lease was allocated by. Once a lease is bound, @@ -167,8 +167,8 @@ export async function bindLeaseNamedDevice(options: { /** A refused allocation is discarded unless the daemon explicitly reported reuse. */ function leaseHonoursSpec(lease: Lease, spec: LeaseDeviceSpec): boolean { if (!lease.device) return false; - const granted = lease.device.deviceMode; - return granted === undefined || spec.deviceMode === undefined || granted === spec.deviceMode; + // Full is a guarantee and slim is best effort, so a full grant serves a slim request. + return !(spec.deviceMode === 'full' && lease.device.deviceMode === 'slim'); } async function refuseUnhonouredSpecLease( diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 55217ff6e6..c9b50be0bd 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -573,7 +573,7 @@ Proxy lease by device spec: Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore - A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with another deviceMode than the request named, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. + A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with deviceMode slim for a full request, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. Human takeover of a leased remote device: Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 9292299070..a228b14959 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -78,7 +78,7 @@ agent-device open MyApp --platform ios --device-mode full With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. -A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports another mode than `--device-mode` asked for is released, and the command fails with reason `device-spec-unsupported`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. +A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports `slim` for `--device-mode full` is released, and the command fails with reason `device-spec-unsupported`. `slim` is best effort, so a `full` simulator can serve `--device-mode slim`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. From 97f95411c795401538e89e6de5985710642c8c94 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 10:41:47 +0100 Subject: [PATCH 6/8] docs(lease): qualify the release claim for a reused allocation A refused allocation that reports reuse is not tracked for release, so it has no release and no details.released. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-remote-device-leases.md | 2 +- website/docs/docs/remote-proxy.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 2555f770ae..0dfbf82532 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -351,7 +351,7 @@ A lease that reports no mode is accepted, so the proof does not confirm `deviceM A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. `details.released` reports whether the release succeeded. -An allocation that explicitly reports reuse (`created: false`) is refused without a release. +An allocation that explicitly reports reuse (`created: false`) fails with the same reason, without a release and without `details.released`. A live lease serves a later command only when every spec key that the command names equals the one the lease was allocated by; otherwise the command fails with `CONNECTION_DEVICE_CONFLICT`. The client never picks a device by spec from the daemon inventory. `--target` stays the only class selector. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a228b14959..a6858286bb 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -78,7 +78,7 @@ agent-device open MyApp --platform ios --device-mode full With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. -A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports `slim` for `--device-mode full` is released, and the command fails with reason `device-spec-unsupported`. `slim` is best effort, so a `full` simulator can serve `--device-mode slim`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. +A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports `slim` for `--device-mode full` fails the command with reason `device-spec-unsupported`, and is released unless the daemon explicitly reported `created:false` (reuse). `slim` is best effort, so a `full` simulator can serve `--device-mode slim`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. From a2796d8275a0c7eb7ca7f07267c9cf736e6f6542 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 11:39:35 +0100 Subject: [PATCH 7/8] test(lease): give the device mode its own iOS allocation case deviceMode was sent on an Android allocation. The forwarding held, but the request was not a valid one, so the mode moves to an iOS fixture. Co-Authored-By: Claude Opus 5.5 --- .../client/src/__tests__/index-leases.test.ts | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/client/src/__tests__/index-leases.test.ts b/packages/client/src/__tests__/index-leases.test.ts index fcc3a90240..cb51273935 100644 --- a/packages/client/src/__tests__/index-leases.test.ts +++ b/packages/client/src/__tests__/index-leases.test.ts @@ -44,13 +44,7 @@ test('lease allocation sends the device spec and returns the device the backend runId: req.meta?.runId, backend: req.meta?.leaseBackend, deviceKey: 'android:mobile:emulator-5554', - device: { - model: 'Pixel 9', - osVersion: '34', - imageTag: 'google_apis', - deviceMode: 'full', - physical: false, - }, + device: { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }, }, }, })); @@ -64,19 +58,46 @@ test('lease allocation sends the device spec and returns the device the backend platform: 'android', osVersion: '34', imageTag: 'google_apis', - deviceMode: 'full', physical: true, }); assert.equal(setup.calls[0]?.flags?.osVersion, '34'); assert.equal(setup.calls[0]?.flags?.imageTag, 'google_apis'); - assert.equal(setup.calls[0]?.flags?.deviceMode, 'full'); assert.equal(setup.calls[0]?.flags?.physical, true); assert.deepEqual(lease.device, { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', - deviceMode: 'full', physical: false, }); }); + +test('lease allocation sends the iOS simulator mode and returns the mode the backend granted', async () => { + const device = { model: 'iPhone 17 Pro', osVersion: '26.2', deviceMode: 'full', physical: false }; + const setup = createClientTransport(async (req) => ({ + ok: true, + data: { + lease: { + leaseId: 'lease-mode', + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + backend: req.meta?.leaseBackend, + deviceKey: 'ios:mobile:SIM-1', + device, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const lease = await client.leases.allocate({ + tenant: 'proxy', + runId: 'proxy-run', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + platform: 'ios', + deviceMode: 'full', + }); + + assert.equal(setup.calls[0]?.flags?.deviceMode, 'full'); + assert.deepEqual(lease.device, device); +}); From 21590b94705d74a807b92d604b194ea595d7a488 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Wed, 4 Nov 2026 11:39:35 +0100 Subject: [PATCH 8/8] docs(lease): exclude reused allocations from the help's release claim Co-Authored-By: Claude Opus 5.5 --- src/commands/schema/cli-help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index c9b50be0bd..b4a8e203c7 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -573,7 +573,7 @@ Proxy lease by device spec: Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore - A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with deviceMode slim for a full request, the CLI releases that lease. The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether the release succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. + A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with deviceMode slim for a full request, the CLI releases that lease, unless the allocation explicitly reports reuse (created: false). The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether a release it attempted succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. Human takeover of a leased remote device: Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported.