diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index b126b7e0c8..0dfbf82532 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -329,23 +329,29 @@ 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. 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. +A backend can report the granted mode as `lease.device.deviceMode`. +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'`. `details.released` reports whether the release succeeded. +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/packages/client/src/__tests__/index-leases.test.ts b/packages/client/src/__tests__/index-leases.test.ts index f93091e858..cb51273935 100644 --- a/packages/client/src/__tests__/index-leases.test.ts +++ b/packages/client/src/__tests__/index-leases.test.ts @@ -71,3 +71,33 @@ test('lease allocation sends the device spec and returns the device the backend 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); +}); 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..49170a007c 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; }; @@ -53,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 3f6e2b9966..2e71dc5fca 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[] = []; @@ -367,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, }; } @@ -378,14 +383,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 +398,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/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index a9d3f7f6f7..0b120eca98 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,16 +64,23 @@ 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: Required = { + osVersion: ' >=26 ', + imageTag: 'google_apis', + deviceMode: 'full', + physical: true, + }; + const grantedDevice: LeaseDevice = { ...GRANTED_DEVICE, deviceMode: 'full' }; 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) => { requests.push(request); - return grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device: GRANTED_DEVICE }); + return grantedLease(request, { deviceKey: REMOTE_DEVICE_KEY, device: grantedDevice }); }, release: unexpectedCommandCall, }), @@ -82,21 +90,95 @@ 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); + 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'); - 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 }, - device: GRANTED_DEVICE, + spec, + device: grantedDevice, + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +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( + 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 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-'); + + 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 }); }); @@ -479,7 +561,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' }, + flags: { + ...flags, + osVersion: '>=26', + imageTag: 'google_apis', + deviceMode: 'full', + physical: true, + }, client: createTestClient({ listDevices: unexpectedCommandCall, heartbeat: async () => { @@ -494,7 +582,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 +630,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 +646,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 +666,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 +680,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 9a43e09a05..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,12 +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, - 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 b677491509..a403945960 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'; @@ -63,7 +64,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 }; @@ -109,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 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, * the spec leaves the command flags: the device it chose is named by its identity instead. */ @@ -127,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) { @@ -138,9 +140,7 @@ export async function bindLeaseNamedDevice(options: { await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); } applyDeviceScopeSelector(flags, leased); - delete flags.osVersion; - delete flags.imageTag; - delete flags.physical; + for (const key of LEASE_DEVICE_SPEC_KEYS) delete flags[key]; return { state: { ...options.state, @@ -165,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; + // 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( leaseLedger: LeaseLedger, state: RemoteConnectionState, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 47b34af2b6..b4a8e203c7 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -570,10 +570,10 @@ 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. + 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. 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 4f7819c217..a6858286bb 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`. +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 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