From 5130d7577e152255aae1a777c6dea60da37eacb6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 13:39:13 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(proxy):=20lease=20by=20device=20spec?= =?UTF-8?q?=20=E2=80=94=20--os,=20--image-tag,=20--physical=20honoured=20o?= =?UTF-8?q?r=20refused=20by=20the=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0007-remote-device-leases.md | 18 + .../src/flag-definitions-target.ts | 36 ++ packages/command-registry/src/flag-groups.ts | 2 + .../src/__tests__/lease-scope.test.ts | 39 +++ packages/contracts/src/cli-flags.ts | 5 + packages/contracts/src/client-app.ts | 17 +- packages/contracts/src/client-lease.ts | 71 ++-- packages/contracts/src/facades/client.ts | 2 + packages/contracts/src/lease-scope.ts | 91 +++++- .../contracts/src/remote-config-fields.ts | 3 + packages/contracts/src/request-envelope.ts | 3 + packages/daemon/src/server/request-router.ts | 14 + packages/remote/src/remote-config-schema.ts | 4 + .../remote/src/remote-connection-state.ts | 13 +- scripts/integration-progress-model.ts | 6 + .../agent-device-client-leases.test.ts | 38 +++ .../lease-device-spec-refusal.test.ts | 134 ++++++++ ...emote-connection-proxy-device-spec.test.ts | 309 ++++++++++++++++++ src/cli.ts | 6 +- src/cli/commands/__tests__/generic.test.ts | 39 +++ src/cli/commands/connection-runtime.ts | 17 +- src/cli/commands/generic.ts | 28 +- src/cli/commands/proxy-lease-device.ts | 124 ++++++- src/cli/commands/router-types.ts | 3 + src/cli/commands/router.ts | 1 + src/cli/connection/profile-fields.ts | 20 +- src/cli/connection/proxy-profile.ts | 3 + src/client/lease-client.ts | 6 + src/commands/command-flags.ts | 3 + src/commands/management/app.ts | 9 +- src/commands/management/install.ts | 11 + src/commands/management/lease-device-spec.ts | 21 ++ src/commands/schema/cli-help.ts | 6 + src/commands/schema/command-overrides.ts | 2 + src/daemon-client/daemon-client-rpc.test.ts | 30 ++ .../command-tools-operator-inputs.test.ts | 12 + website/docs/docs/remote-proxy.md | 20 ++ 37 files changed, 1111 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/lease-device-spec-refusal.test.ts create mode 100644 src/__tests__/remote-connection-proxy-device-spec.test.ts create mode 100644 src/commands/management/lease-device-spec.ts diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 993838b890..dde52a2e03 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -289,3 +289,21 @@ A daemon policy `leases.require` (ADR 0029) refuses requests that name no lease. The proxy token is one credential for every client. Thus, a host that serves several clients through one proxy authenticates each client. The host sets the tenant, session isolation, and lease of that client on every request that it forwards. + +## Device spec + +A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, 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 any of the three keys, 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`. +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. +A backend older than the spec returns a lease without `lease.device`. +The client then releases that lease and fails with the same reason. +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. +Rejected: partial honouring of the keys that a daemon understands, because a silent mismatch is worse than a refusal; a separate `--device-class` flag, because `--target` already names the class. diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 7ca52fcaa2..26390c1d05 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -52,6 +52,42 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: true, recorded: true, }, + { + key: 'osVersion', + names: ['--os'], + type: 'string', + usageLabel: '--os ', + usageDescription: + 'Proxy lease: OS version or range the backend picks the device by (Android: API level); refused without such a backend', + inputDescription: + 'OS version or range for the device a proxy backend leases, for example 18.6.2 or ">=26" (Android: API level). The backend validates it; a daemon without such a backend refuses it with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, + { + key: 'imageTag', + names: ['--image-tag'], + type: 'string', + usageLabel: '--image-tag ', + usageDescription: + 'Proxy lease: Android system image tag the backend picks the device by; refused without such a backend', + inputDescription: + 'Android system image tag for the device a proxy backend leases, for example google_apis_playstore. A daemon without such a backend refuses it with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, + { + key: 'physical', + names: ['--physical'], + type: 'boolean', + usageLabel: '--physical', + usageDescription: + 'Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend', + inputDescription: + 'Ask a proxy backend to lease a physical device instead of a simulator or emulator. A daemon without such a backend refuses it with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, { key: 'stale', names: ['--stale'], diff --git a/packages/command-registry/src/flag-groups.ts b/packages/command-registry/src/flag-groups.ts index f3f4ae1804..4249f9daff 100644 --- a/packages/command-registry/src/flag-groups.ts +++ b/packages/command-registry/src/flag-groups.ts @@ -33,6 +33,8 @@ export const METRO_PREPARE_FLAGS = flagKeys( ); export const METRO_RELOAD_FLAGS = flagKeys('metroHost', 'metroPort', 'bundleUrl'); +// The device spec a proxy lease is allocated by; the commands that allocate a proxy lease take it. +export const LEASE_DEVICE_SPEC_FLAGS = flagKeys('osVersion', 'imageTag', 'physical'); export const REPEATED_TOUCH_FLAGS = flagKeys( 'count', 'intervalMs', diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index d83bc96da3..c1c7773ab3 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -14,6 +14,8 @@ import { leaseScopeToRequestMeta, parseLeaseDeviceKey, readLeaseAllocateProviderFlags, + readLeaseDevice, + readLeaseDeviceSpec, } from '../lease-scope.ts'; test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { @@ -245,6 +247,43 @@ test('readLeaseAllocateProviderFlags carries the provider-allocation flags and d assert.deepEqual(readLeaseAllocateProviderFlags(undefined), {}); }); +test('readLeaseAllocateProviderFlags carries every device-spec key a request names, whatever its value', () => { + assert.deepEqual( + readLeaseAllocateProviderFlags({ + platform: 'android', + osVersion: '34', + imageTag: 'google_apis_playstore', + physical: true, + }), + { platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', physical: true }, + ); + // A malformed key still reaches the daemon, which refuses it rather than never seeing it. + assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, physical: false }), { + osVersion: 26, + physical: false, + }); +}); + +test('readLeaseDeviceSpec names only a non-empty version or tag and physical: true', () => { + const padded = { osVersion: ' >=26 ', imageTag: '', physical: false }; + assert.deepEqual(readLeaseDeviceSpec(padded), { osVersion: '>=26' }); + assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis', physical: true }), { + imageTag: 'google_apis', + physical: true, + }); + assert.deepEqual(readLeaseDeviceSpec({}), {}); +}); + +test('readLeaseDevice reads the granted facts and rejects an echo missing one', () => { + const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; + assert.deepEqual(readLeaseDevice(pixel), pixel); + const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0', physical: true }; + assert.deepEqual(readLeaseDevice(iphone), iphone); + assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro', osVersion: '26.0' }), undefined); + assert.equal(readLeaseDevice({ osVersion: '26.0', physical: true }), undefined); + assert.equal(readLeaseDevice(undefined), undefined); +}); + test('isInactiveLeaseError recognizes the lease being gone, and refuses a request mismatch', () => { for (const reason of ['LEASE_NOT_FOUND', 'LEASE_EXPIRED', 'LEASE_REVOKED']) { assert.equal( diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 30cb73740f..dcf3103b5a 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -61,6 +61,11 @@ export type CliFlags = CloudProviderProfileFields & device?: string; udid?: string; serial?: string; + // The device spec only a backend in front of the daemon honours; a daemon refuses a request + // that names any of them (`LEASE_DEVICE_SPEC_KEYS`). + osVersion?: string; + imageTag?: string; + physical?: boolean; stale?: boolean; iosSimulatorDeviceSet?: string; iosXctestrunFile?: string; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index ef015bc78f..32f6c3a8cc 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -18,6 +18,7 @@ import type { DeviceCommandBaseOptions, } from './client-connection.ts'; import type { AgentDeviceSessionDevice, StartupPerfSample } from './client-device-view.ts'; +import type { LeaseDeviceSpec } from './client-lease.ts'; export type DeviceSelectionReason = | 'explicit-selector' @@ -40,13 +41,15 @@ export type DeviceSelectionMetadata = { }; export type AppInstallOptions = AgentDeviceRequestOverrides & - AgentDeviceSelectionOptions & { + AgentDeviceSelectionOptions & + LeaseDeviceSpec & { app?: string; appPath: string; }; export type AppDeployOptions = AgentDeviceRequestOverrides & - AgentDeviceSelectionOptions & { + AgentDeviceSelectionOptions & + LeaseDeviceSpec & { app: string; appPath: string; }; @@ -62,7 +65,8 @@ export type AppDeployResult = { }; export type AppOpenOptions = AgentDeviceRequestOverrides & - AgentDeviceSelectionOptions & { + AgentDeviceSelectionOptions & + LeaseDeviceSpec & { app?: string; url?: string; surface?: SessionSurface; @@ -190,9 +194,10 @@ export type AppPushOptions = DeviceCommandBaseOptions & { payload: string | JsonObject; }; -export type AppUninstallOptions = DeviceCommandBaseOptions & { - app: string; -}; +export type AppUninstallOptions = DeviceCommandBaseOptions & + LeaseDeviceSpec & { + app: string; + }; export type AppTriggerEventOptions = DeviceCommandBaseOptions & { event: string; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 57628f61e8..eb66300acd 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -14,6 +14,11 @@ export type Lease = { leaseProvider?: string; deviceKey?: string; clientId?: string; + /** + * The device granted for a request that named a device spec, written by the backend that + * honoured the spec. A daemon refuses a spec instead, so only such a backend sets it. + */ + device?: LeaseDevice; /** Present when the daemon keeps this lease through session `close`; an older daemon omits it. */ retainOnClose?: true; createdAt?: number; @@ -21,6 +26,29 @@ export type Lease = { expiresAt?: number; }; +/** + * A device spec a lease request can name beside platform, target and device. Only a backend in + * front of the daemon honours it; a daemon refuses every request that names one, with + * `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. + */ +export type LeaseDeviceSpec = { + /** OS version or range, opaque to agent-device; the backend validates it. */ + osVersion?: string; + /** Android system image tag, for example `google_apis_playstore`. */ + imageTag?: string; + /** `true` asks for a physical device instead of a simulator or emulator. */ + physical?: boolean; +}; + +/** The device facts the backend granted for a {@link LeaseDeviceSpec}. */ +export type LeaseDevice = { + model: string; + /** The exact OS version granted. */ + osVersion: string; + imageTag?: string; + physical: boolean; +}; + /** * A session lease a daemon request ended. A reply to a request that ended leases lists them once in * `endedLeases` at response level: in `data` on success, in `error.details` on failure. A `close`, @@ -35,27 +63,28 @@ export type LeaseOptions = AgentDeviceRequestOverrides & ttlMs?: number; }; -export type LeaseAllocateOptions = LeaseOptions & { - tenant: string; - runId: string; - /** - * The id for the new lease (16-128 hex characters), refused when the daemon already issued it; - * the client's configured `leaseId` is never sent here. Use the returned lease's `leaseId`: a daemon that predates this field mints its own, - * and a run that already holds a lease gets that lease back. - */ - leaseId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - provider?: string; - deviceKey?: string; - clientId?: string; - /** - * Keeps the lease through session `close`; it then ends only through `leases.release`, expiry, or - * daemon shutdown. Asking for it on a lease the same client already holds for the run turns it on - * for that lease; the returned lease's `retainOnClose` says whether the daemon honored it. - */ - retainOnClose?: boolean; -}; +export type LeaseAllocateOptions = LeaseOptions & + LeaseDeviceSpec & { + tenant: string; + runId: string; + /** + * The id for the new lease (16-128 hex characters), refused when the daemon already issued it; + * the client's configured `leaseId` is never sent here. Use the returned lease's `leaseId`: a daemon that predates this field mints its own, + * and a run that already holds a lease gets that lease back. + */ + leaseId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; + /** + * Keeps the lease through session `close`; it then ends only through `leases.release`, expiry, or + * daemon shutdown. Asking for it on a lease the same client already holds for the run turns it on + * for that lease; the returned lease's `retainOnClose` says whether the daemon honored it. + */ + retainOnClose?: boolean; + }; export type LeaseScopedOptions = LeaseOptions & { tenant?: string; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index f10e131d96..be3501c42c 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -80,6 +80,8 @@ export type { HumanControlHoldScope, Lease, LeaseAllocateOptions, + LeaseDevice, + LeaseDeviceSpec, LeaseOptions, LeaseScopedOptions, } from '../client-lease.ts'; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 60168b4386..81e5ba74b7 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,8 +1,9 @@ import type { DaemonResponse, LeaseBackend } from '@agent-device/kernel/contracts'; +import { AppError } from '@agent-device/kernel/errors'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; import type { CommandFlags } from './command-flags.ts'; -import type { EndedLease } from './client-lease.ts'; +import type { EndedLease, LeaseDevice, LeaseDeviceSpec } from './client-lease.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; export const CLOUD_LEASE_PROVIDER = 'cloud'; @@ -263,6 +264,23 @@ const LEASE_ALLOCATE_PROVIDER_FLAG_KEYS = [ type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[number]; +/** + * The device-spec keys a lease request can name beside platform, target and device. They ride + * `lease_allocate` like the keys above, but only a backend in front of the daemon honours them: a + * daemon refuses every request that names one, so no request is served on a device that ignored + * part of what it asked for. + */ +export const LEASE_DEVICE_SPEC_KEYS = [ + 'osVersion', + 'imageTag', + 'physical', +] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[]; + +type LeaseDeviceSpecKey = (typeof LEASE_DEVICE_SPEC_KEYS)[number]; + +/** The typed reason a request naming a device spec is refused with. */ +export const DEVICE_SPEC_UNSUPPORTED_REASON = 'device-spec-unsupported'; + // A Cloud provider profile field added without joining the projection would be dropped from the // envelope and fail `prepareSession` on the remote daemon — this makes that omission fail to build. type LeaseAllocateProfileKeysAreExhaustive = @@ -272,20 +290,85 @@ type LeaseAllocateProfileKeysAreExhaustive = const leaseAllocateProfileKeysExhaustive: LeaseAllocateProfileKeysAreExhaustive = true; void leaseAllocateProfileKeysExhaustive; -type LeaseAllocateProviderFlags = Partial>; +type LeaseAllocateProviderFlags = Partial< + Pick +>; -/** Reads the provider-allocation flags out of a request flags bag or a lease-envelope param bag. */ +/** + * Reads the allocation flags out of a request flags bag or a lease-envelope param bag. A device-spec + * key is carried whatever its value, so a daemon refuses a malformed one instead of never seeing it. + */ export function readLeaseAllocateProviderFlags( source: Record | undefined, ): LeaseAllocateProviderFlags { - const flags: Record = {}; + const flags: Record = {}; for (const key of LEASE_ALLOCATE_PROVIDER_FLAG_KEYS) { const value = source?.[key]; if (typeof value === 'string' || typeof value === 'boolean') flags[key] = value; } + for (const key of LEASE_DEVICE_SPEC_KEYS) { + if (source?.[key] !== undefined) flags[key] = source[key]; + } return flags as LeaseAllocateProviderFlags; } +/** The device-spec keys a request names: every key whose value is not `undefined`. */ +export function namedLeaseDeviceSpecKeys( + source: Record | undefined, +): LeaseDeviceSpecKey[] { + return LEASE_DEVICE_SPEC_KEYS.filter((key) => source?.[key] !== undefined); +} + +/** + * The device spec a CLI flags bag or profile asks for. An empty string names no version or tag, + * and only `physical: true` asks for a physical device. + */ +export function readLeaseDeviceSpec(source: { + osVersion?: unknown; + imageTag?: unknown; + physical?: unknown; +}): LeaseDeviceSpec { + return stripUndefined({ + osVersion: nonEmptyString(source.osVersion), + imageTag: nonEmptyString(source.imageTag), + physical: source.physical === true ? true : undefined, + }); +} + +/** The granted device facts on a lease reply, or `undefined` when the reply carries none. */ +export function readLeaseDevice(value: unknown): LeaseDevice | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const model = nonEmptyString(record.model); + const osVersion = nonEmptyString(record.osVersion); + if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined; + const imageTag = nonEmptyString(record.imageTag); + return { + model, + osVersion, + ...(imageTag ? { imageTag } : {}), + physical: record.physical, + }; +} + +/** 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.', + 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.', + }); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + export function leaseScopeToConnectionMetadata( scope: LeaseScope, ): Pick | undefined { diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index f5516f5ac2..182dcc5b80 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -140,6 +140,9 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & device?: string; udid?: string; serial?: string; + osVersion?: string; + imageTag?: string; + physical?: boolean; iosSimulatorDeviceSet?: string; androidDeviceAllowlist?: string; session?: string; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index 2f11845678..98b4661977 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -66,6 +66,9 @@ export type InternalRequestOptions = AgentDeviceClientConfig & runtime?: SessionRuntimeHints; overlayRefs?: boolean; surface?: SessionSurface; + osVersion?: string; + imageTag?: string; + physical?: boolean; activity?: string; launchConsole?: string; launchArgs?: string[]; diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index f1da1f387c..3b1d45b73e 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -56,6 +56,10 @@ import { type RequestExecutionScope, } from './request-execution-scope.ts'; import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; +import { + deviceSpecUnsupportedError, + namedLeaseDeviceSpecKeys, +} from '@agent-device/contracts/lease-scope'; import { canRunReplayScopedAction } from '../core/daemon-command-registry.ts'; import { isWebSession } from '../sessions/web-session-names.ts'; import { @@ -239,6 +243,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { // admission, device work, or a handler that could arm publication. const unsupportedSaveScript = unsupportedSaveScriptFlagResponse(req); if (unsupportedSaveScript) return unsupportedSaveScript; + // A device spec is honoured only by a backend in front of this daemon, which strips it; one that + // reaches here is refused before admission, so no lease is recorded and no device is picked. + const unsupportedDeviceSpec = deviceSpecFlagsResponse(req); + if (unsupportedDeviceSpec) return unsupportedDeviceSpec; let scope: RequestExecutionScope | undefined; let response: DaemonResponse; @@ -542,6 +550,12 @@ function customActionFlagsResponse(req: DaemonRequest): DaemonResponse | undefin return undefined; } +function deviceSpecFlagsResponse(req: DaemonRequest): DaemonResponse | undefined { + const keys = namedLeaseDeviceSpecKeys(req.flags); + if (keys.length === 0) return undefined; + return { ok: false, error: normalizeError(deviceSpecUnsupportedError(keys)) }; +} + /** The text of a fill the caller marked sensitive, or `undefined` for any other request. */ function sensitiveFillText(req: DaemonRequest): string | undefined { if (req.command !== 'fill' || !isSensitiveFillText(req.flags)) return undefined; diff --git a/packages/remote/src/remote-config-schema.ts b/packages/remote/src/remote-config-schema.ts index 90e7653b6b..234bbb8509 100644 --- a/packages/remote/src/remote-config-schema.ts +++ b/packages/remote/src/remote-config-schema.ts @@ -47,6 +47,10 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'device', type: 'string' }, { key: 'udid', type: 'string' }, { key: 'serial', type: 'string' }, + // 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: 'physical', type: 'boolean', env: false }, { key: 'iosSimulatorDeviceSet', type: 'string', diff --git a/packages/remote/src/remote-connection-state.ts b/packages/remote/src/remote-connection-state.ts index 413495cfcb..fb4ad441b5 100644 --- a/packages/remote/src/remote-connection-state.ts +++ b/packages/remote/src/remote-connection-state.ts @@ -17,6 +17,7 @@ import { import { publishFileSync } from '@agent-device/host-kit/file'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { CliFlags } from '@agent-device/contracts/command'; +import type { LeaseDevice, LeaseDeviceSpec } from '@agent-device/contracts/client'; import { leaseBackendForPlatform, platformForLeaseBackend, @@ -50,6 +51,10 @@ export type RemoteConnectionState = { clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; + /** The device spec the lease was allocated by; a later request naming another one conflicts. */ + deviceSpec?: LeaseDeviceSpec; + /** The device facts the proxy backend granted for {@link deviceSpec}. */ + leaseDevice?: LeaseDevice; runtime?: SessionRuntimeHints; metro?: { projectRoot: string; @@ -498,10 +503,16 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'deviceKey', 'clientId', ]) && - isOptionalRemoteConnectionDaemonState(record.daemon) + isOptionalRemoteConnectionDaemonState(record.daemon) && + isOptionalRecord(record.deviceSpec) && + isOptionalRecord(record.leaseDevice) ); } +function isOptionalRecord(value: unknown): boolean { + return value === undefined || (!!value && typeof value === 'object' && !Array.isArray(value)); +} + function hasStringFields(record: Record, fields: string[]): boolean { return fields.every((field) => typeof record[field] === 'string'); } diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index c86c1b7ea5..1ecbfd6824 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -251,6 +251,12 @@ function summarizeProviderScenarioFlagExclusions() { owner: 'connection/runtime/request policy tests', keys: ['force', 'noLogin', 'sessionLock'], }, + { + // 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'], + }, { name: 'cloud artifact provider lookup', owner: diff --git a/src/__tests__/agent-device-client-leases.test.ts b/src/__tests__/agent-device-client-leases.test.ts index 02ea6d0d58..b295b98f0e 100644 --- a/src/__tests__/agent-device-client-leases.test.ts +++ b/src/__tests__/agent-device-client-leases.test.ts @@ -33,3 +33,41 @@ test('lease allocation carries the TestMu device type to the provider flags', as assert.equal(setup.calls[0]?.flags?.providerDeviceType, 'real'); assert.equal(setup.calls[0]?.flags?.providerOsVersion, '18'); }); + +test('lease allocation sends the device spec and returns the device the backend granted', async () => { + const setup = createTransport(async (req) => ({ + ok: true, + data: { + lease: { + leaseId: 'lease-spec', + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + backend: req.meta?.leaseBackend, + deviceKey: 'android:mobile:emulator-5554', + device: { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const lease = await client.leases.allocate({ + tenant: 'proxy', + runId: 'proxy-run', + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + platform: 'android', + osVersion: '34', + imageTag: 'google_apis', + 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?.physical, true); + assert.deepEqual(lease.device, { + model: 'Pixel 9', + osVersion: '34', + imageTag: 'google_apis', + physical: false, + }); +}); diff --git a/src/__tests__/lease-device-spec-refusal.test.ts b/src/__tests__/lease-device-spec-refusal.test.ts new file mode 100644 index 0000000000..f8673a08cf --- /dev/null +++ b/src/__tests__/lease-device-spec-refusal.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import { createDaemonProxyServer } from '@agent-device/proxy'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '@agent-device/test-kit'; +import { isInactiveLeaseError } from '@agent-device/contracts/lease-scope'; +import { AppError } from '@agent-device/kernel/errors'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; +import { startPublicDaemonRuntime } from './test-utils/public-daemon-runtime.ts'; + +const LEASE_ID = 'abcdef0123456789abcdef0123456789'; + +function isDeviceSpecRefusal(keys: string[]) { + return (error: unknown) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + error.details?.reason === 'device-spec-unsupported' && + JSON.stringify(error.details?.keys) === JSON.stringify(keys); +} + +async function assertNoLeaseRecorded(client: ReturnType) { + await assert.rejects( + client.leases.heartbeat({ + tenant: 'proxy', + runId: 'run-spec', + leaseId: LEASE_ID, + leaseBackend: 'ios-instance', + }), + (error: unknown) => isInactiveLeaseError(error), + ); +} + +test('a daemon refuses a lease allocate naming a device spec and records no lease', async () => { + const daemon = await startPublicDaemonRuntime({ serverMode: 'http' }); + try { + const client = createAgentDeviceClient({ + daemonBaseUrl: daemon.baseUrl, + daemonAuthToken: daemon.runtime.token, + daemonTransport: 'http', + }); + await assert.rejects( + client.leases.allocate({ + tenant: 'proxy', + runId: 'run-spec', + leaseId: LEASE_ID, + leaseBackend: 'ios-instance', + platform: 'ios', + osVersion: '>=26', + physical: true, + }), + isDeviceSpecRefusal(['osVersion', 'physical']), + ); + await assertNoLeaseRecorded(client); + // The same allocate without the spec is recorded, so the heartbeat above proves the refusal. + const lease = await client.leases.allocate({ + tenant: 'proxy', + runId: 'run-spec', + leaseId: LEASE_ID, + leaseBackend: 'ios-instance', + platform: 'ios', + }); + assert.equal(lease.leaseId, LEASE_ID); + assert.equal(lease.device, undefined); + } finally { + await daemon.close(); + } +}); + +test('a daemon behind agent-device proxy refuses a lease allocate naming a device spec', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemon = await startPublicDaemonRuntime({ serverMode: 'http' }); + const proxy = createDaemonProxyServer({ + upstreamBaseUrl: daemon.baseUrl, + upstreamToken: daemon.runtime.token, + clientToken: 'client-secret', + }); + try { + const proxyPort = await listenOnLoopback(proxy); + const client = createAgentDeviceClient({ + daemonBaseUrl: `http://127.0.0.1:${proxyPort}/agent-device`, + daemonAuthToken: 'client-secret', + daemonTransport: 'http', + }); + await assert.rejects( + client.leases.allocate({ + tenant: 'proxy', + runId: 'run-spec', + leaseId: LEASE_ID, + leaseBackend: 'android-instance', + platform: 'android', + imageTag: 'google_apis_playstore', + }), + isDeviceSpecRefusal(['imageTag']), + ); + await assertNoLeaseRecorded(client); + } finally { + await closeLoopbackServer(proxy); + await daemon.close(); + } +}); + +test('open --os against a local daemon fails with device-spec-unsupported', async () => { + const { root, home, project } = makeTempWorkspace(); + const daemon = await startPublicDaemonRuntime({ serverMode: 'http' }); + try { + const result = await runCliCapture( + ['open', 'MyApp', '--platform', 'ios', '--os', '>=26', '--json'], + { + cwd: project, + env: { + HOME: home, + AGENT_DEVICE_DAEMON_BASE_URL: daemon.baseUrl, + AGENT_DEVICE_DAEMON_AUTH_TOKEN: daemon.runtime.token, + }, + useRealDaemonClient: true, + }, + ); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'UNSUPPORTED_OPERATION'); + assert.equal(payload.error.details.reason, 'device-spec-unsupported'); + assert.deepEqual(payload.error.details.keys, ['osVersion']); + } finally { + await daemon.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/remote-connection-proxy-device-spec.test.ts b/src/__tests__/remote-connection-proxy-device-spec.test.ts new file mode 100644 index 0000000000..9cc8c3b7bb --- /dev/null +++ b/src/__tests__/remote-connection-proxy-device-spec.test.ts @@ -0,0 +1,309 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; +import { test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { Lease, LeaseDevice, LeaseDeviceSpec } from '@agent-device/contracts/client'; +import { AppError } from '@agent-device/kernel/errors'; +import { + connectionWorkspace, + createTestClient, + type LeaseAllocateRequest, + type LeaseReleaseRequest, + seedConnectionState, + unexpectedCommandCall, +} from './remote-connection.fixtures.ts'; + +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), +})); + +import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { + readActiveConnectionState, + readRemoteConnectionState, +} from '@agent-device/remote/remote-connection-state'; +import { connectCommand } from './test-utils/host-bound-commands.ts'; +import { withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; + +bindTestCommandHost(); + +const GRANTED_DEVICE: LeaseDevice = { + model: 'iPhone 17 Pro', + osVersion: '26.1', + physical: false, +}; + +function proxyWorkspace( + prefix: string, + seed: { leaseId?: string; deviceKey?: string; deviceSpec?: LeaseDeviceSpec } = {}, +) { + const workspace = connectionWorkspace(prefix); + fs.writeFileSync( + workspace.remoteConfigPath, + JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }), + ); + seedConnectionState({ + stateDir: workspace.stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath: workspace.remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + ...(seed.leaseId + ? { leaseId: seed.leaseId, leaseBackend: 'ios-instance', platform: 'ios' } + : {}), + ...(seed.deviceKey ? { deviceKey: seed.deviceKey } : {}), + ...(seed.deviceSpec ? { deviceSpec: seed.deviceSpec } : {}), + }, + }); + const flags = { + json: true, + help: false, + version: false, + stateDir: workspace.stateDir, + remoteConfig: workspace.remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + } as const; + return { ...workspace, flags }; +} + +function grantedLease(request: LeaseAllocateRequest, extra: Partial = {}): Lease { + return { + leaseId: 'lease-1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'ios-instance', + leaseProvider: request.leaseProvider, + clientId: request.clientId, + deviceKey: 'ios:mobile:SIM-REMOTE', + ...extra, + }; +} + +function renewingClient(heartbeats: string[]) { + return createTestClient({ + listDevices: unexpectedCommandCall, + allocate: unexpectedCommandCall, + release: unexpectedCommandCall, + heartbeat: async (request) => { + heartbeats.push(request.leaseId); + return { + leaseId: request.leaseId, + tenantId: 'proxy', + runId: 'proxy-client-1', + backend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + }; + }, + }); +} + +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 materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, device: 'iPhone 17 Pro', osVersion: '>=26', physical: true }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => { + requests.push(request); + return grantedLease(request, { device: GRANTED_DEVICE }); + }, + release: unexpectedCommandCall, + }), + }); + + assert.equal(requests.length, 1); + 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); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, 'lease-1'); + assert.deepEqual(state?.deviceSpec, { osVersion: '>=26', physical: true }); + assert.deepEqual(state?.leaseDevice, GRANTED_DEVICE); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a lease returned without lease.device for a spec request is released and the command fails with device-spec-unsupported', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-noecho-'); + const releases: LeaseReleaseRequest[] = []; + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '34', imageTag: 'google_apis_playstore' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => grantedLease(request), + 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) === '["osVersion","imageTag"]' && + error.details?.leaseId === 'lease-1' && + error.details?.released === true, + ); + assert.equal(releases.length, 1); + assert.equal(releases[0]?.leaseId, 'lease-1'); + assert.equal(releases[0]?.deviceKey, 'ios:mobile:SIM-REMOTE'); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a failed release of an unconfirmed spec lease still fails the command with device-spec-unsupported', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace( + 'agent-device-proxy-device-spec-release-fails-', + ); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, physical: true }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => grantedLease(request), + release: async () => { + throw new AppError('COMMAND_FAILED', 'backend unreachable'); + }, + }), + }), + (error: AppError) => + error.code === 'UNSUPPORTED_OPERATION' && + error.details?.reason === 'device-spec-unsupported' && + error.details?.released === false && + error.details?.leaseId === 'lease-1', + ); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a later open on a live spec lease is served when it names the same spec', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-same-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + deviceSpec: { osVersion: '>=26' }, + }); + const heartbeats: string[] = []; + + const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '>=26' }, + client: renewingClient(heartbeats), + }); + + assert.deepEqual(heartbeats, ['lease-1']); + assert.equal(materialized.flags.udid, 'SIM-REMOTE'); + assert.equal(materialized.flags.osVersion, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a later open naming a different --os on a live spec lease is refused with CONNECTION_DEVICE_CONFLICT', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-conflict-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + deviceSpec: { osVersion: '>=26' }, + }); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '18.6.2' }, + client: renewingClient([]), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && + JSON.stringify(error.details?.keys) === '["osVersion"]' && + error.details?.deviceKey === 'ios:mobile:SIM-REMOTE', + ); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, 'lease-1'); + assert.deepEqual(state?.deviceSpec, { osVersion: '>=26' }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a spec named on a live lease allocated without one is refused with CONNECTION_DEVICE_CONFLICT', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-unspecced-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + }); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'install', + flags: { ...flags, physical: true }, + client: renewingClient([]), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && + JSON.stringify(error.details?.keys) === '["physical"]', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy stores the device spec as profile defaults like platform and device', async () => { + const { tempRoot, stateDir } = connectionWorkspace('agent-device-connect-proxy-spec-'); + + await withCapturedStdoutAsync(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + platform: 'android', + osVersion: '34', + imageTag: 'google_apis_playstore', + physical: true, + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + const generated = JSON.parse(fs.readFileSync(state.remoteConfigPath, 'utf8')) as Record< + string, + unknown + >; + assert.equal(generated.platform, 'android'); + assert.equal(generated.osVersion, '34'); + assert.equal(generated.imageTag, 'google_apis_playstore'); + assert.equal(generated.physical, true); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli.ts b/src/cli.ts index c7572260ac..9973593f07 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { sendToDaemon, } from './daemon-client/daemon-client.ts'; import fs from 'node:fs'; -import type { BatchStep } from '@agent-device/contracts/client'; +import type { BatchStep, LeaseDevice } from '@agent-device/contracts/client'; import type { ReplayTestReporterRuntime } from './cli/replay-test/reporting.ts'; import { createCommandProgressState, @@ -338,6 +338,8 @@ type CliRunContext = { effectiveFlags: CliFlags; resolvedRuntime: SessionRuntimeHints | undefined; connectionMetadata: RemoteConnectionRequestMetadata | undefined; + /** The device a proxy backend granted for the lease's device spec; `open` reports it. */ + leaseDevice?: LeaseDevice; parsedBatchSteps: BatchStep[] | undefined; }; @@ -462,6 +464,7 @@ async function resolveRemoteContext( ctx.effectiveFlags = materialized.flags; ctx.resolvedRuntime = materialized.runtime; ctx.connectionMetadata = materialized.connection; + ctx.leaseDevice = materialized.leaseDevice; } if ( shouldWarnOpenMayMissRemoteRuntime({ @@ -603,6 +606,7 @@ async function dispatchCliCommand( debug: ctx.debugOutputEnabled, replayTestReporterRuntime, commandProgress, + leaseDevice: ctx.leaseDevice, }) ) { return; diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index d32e33716a..086b4ab766 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -112,3 +112,42 @@ test('the generic CLI path prints the response warnings', async () => { assert.equal(out, `Typed "hi"\nWarning: ${warning}\n`); }); + +test('open --json prints the device a proxy backend granted under lease.device', async () => { + const client = createAgentDeviceClient( + { session: 'qa' }, + { + transport: async (req): Promise => { + assert.equal(req.command, 'open'); + return { ok: true, data: { session: 'qa', appName: 'MyApp' } }; + }, + }, + ); + const device = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; + + const json = await withCapturedStdoutAsync(() => + runGenericClientBackedCommand({ + host: createTestHostEnvironment(), + command: 'open' as ClientBackedCliCommandName, + positionals: ['MyApp'], + flags: { json: true } as CliFlags, + client, + leaseDevice: device, + }), + ); + const parsed = JSON.parse(json) as { data: Record }; + assert.deepEqual(parsed.data.lease, { device }); + assert.equal(parsed.data.appName, 'MyApp'); + + const human = await withCapturedStdoutAsync(() => + runGenericClientBackedCommand({ + host: createTestHostEnvironment(), + command: 'open' as ClientBackedCliCommandName, + positionals: ['MyApp'], + flags: {} as CliFlags, + client, + leaseDevice: device, + }), + ); + assert.match(human, /Leased device: Pixel 9, 34, google_apis, virtual/); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 28a8a36e32..231b0ea479 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -9,7 +9,7 @@ import { resolveRemoteConfigProfile } from '@agent-device/remote/remote-config'; // resolveRemoteConfigProfile. import { readRemoteConfigFile } from '@agent-device/remote/remote-config-core'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; -import { isInactiveLeaseError } from '@agent-device/contracts/lease-scope'; +import { isInactiveLeaseError, readLeaseDeviceSpec } from '@agent-device/contracts/lease-scope'; import { narrowConnectionPlatform, buildRemoteConnectionDaemonState, @@ -22,7 +22,7 @@ import { type RemoteConnectionRequestMetadata, } from '@agent-device/remote/remote-connection-state'; import { profileToCliFlags } from '../remote-config-flags.ts'; -import type { BatchStep } from '@agent-device/contracts/client'; +import type { BatchStep, LeaseDevice } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { isSessionRuntimePlatform, @@ -39,7 +39,7 @@ import { connectionProviderCapabilities, primeProviderConnection, } from '../connection/provider-policy.ts'; -import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; +import { readHostedDeviceProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; import { bindLeaseNamedDevice, @@ -73,6 +73,8 @@ export async function materializeRemoteConnectionForCommand(options: { flags: CliFlags; runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; + /** The device the proxy backend granted for the lease's device spec, when it named one. */ + leaseDevice?: LeaseDevice; }> { const { command, flags, client, host } = options; if (!flags.remoteConfig) { @@ -202,6 +204,7 @@ export async function materializeRemoteConnectionForCommand(options: { }, runtime: nextRuntime, connection: buildRemoteConnectionRequestMetadata(nextState), + leaseDevice: nextState.leaseDevice, }; } @@ -925,18 +928,14 @@ async function allocateOrReuseLease( serial: flags.serial, iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, androidDeviceAllowlist: flags.androidDeviceAllowlist, + ...readLeaseDeviceSpec(flags), providerApp: initialApp ?? flags.providerApp, providerOsVersion: flags.providerOsVersion, providerDeviceType: flags.providerDeviceType, providerProject: flags.providerProject, providerBuild: flags.providerBuild, providerSessionName: flags.providerSessionName, - ...readCloudDeviceFeatureProfileFields(flags), - awsProjectArn: flags.awsProjectArn, - awsDeviceArn: flags.awsDeviceArn, - awsAppArn: flags.awsAppArn, - awsRegion: flags.awsRegion, - awsInteractionMode: flags.awsInteractionMode, + ...readHostedDeviceProfileFields(flags), }); return { lease, acquired: true }; } diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 655339a478..1776397413 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -10,6 +10,9 @@ import { isNonDefaultResponseLevel } from '@agent-device/kernel/contracts'; import { writeCommandOutput, writeCliOutput } from './shared.ts'; import type { ClientBackedCliCommandName } from './client-backed.ts'; import type { ClientCommandParams } from './router-types.ts'; +import type { CliOutput } from '../../commands/command-contract.ts'; +import type { LeaseDevice } from '@agent-device/contracts/client'; +import { isRecord } from '@agent-device/kernel/record'; export async function runGenericClientBackedCommand({ command, @@ -20,8 +23,9 @@ export async function runGenericClientBackedCommand({ debug, replayTestReporterRuntime, commandProgress, + leaseDevice, }: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise { - const { result, cliOutput } = await runCliCommandWithOutput({ + const { result, cliOutput: formatted } = await runCliCommandWithOutput({ client, command: command as CommandName, positionals, @@ -29,6 +33,10 @@ export async function runGenericClientBackedCommand({ commandProgress, env: host.env, }); + const cliOutput = + command === 'open' && formatted && leaseDevice + ? withLeaseDevice(formatted, leaseDevice) + : formatted; // A non-default responseLevel returns a leveled payload (e.g. the snapshot // digest { nodeCount, refs }) that the per-command CLI formatters assume away — // they serialize the default shape and drop the digest fields. Emit the leveled @@ -83,3 +91,21 @@ async function writeGenericCliOutput( ); return 0; } + +/** Adds the device a proxy backend granted for the lease's device spec to the `open` output. */ +function withLeaseDevice(output: CliOutput, device: LeaseDevice): CliOutput { + const lease = { lease: { device } }; + const facts = [ + device.model, + device.osVersion, + device.imageTag, + device.physical ? 'physical' : 'virtual', + ].filter((fact): fact is string => Boolean(fact)); + const leasedLine = `Leased device: ${facts.join(', ')}`; + return { + ...output, + data: isRecord(output.data) ? { ...output.data, ...lease } : output.data, + ...(isRecord(output.jsonData) ? { jsonData: { ...output.jsonData, ...lease } } : {}), + text: output.text ? `${output.text}\n${leasedLine}` : leasedLine, + }; +} diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index f197ee78c6..6d941d1ba9 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -12,7 +12,13 @@ import { type ConnectionDeviceScope, type RemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, errorMessage } from '@agent-device/kernel/errors'; +import { + deviceSpecUnsupportedError, + namedLeaseDeviceSpecKeys, + readLeaseDeviceSpec, +} from '@agent-device/contracts/lease-scope'; +import type { LeaseDeviceSpec } from '@agent-device/contracts/client'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; @@ -52,6 +58,17 @@ export async function resolveProxyLeaseState(options: { 'No active proxy device lease for this session; run open first.', ); } + // A device spec is only for the backend behind the proxy to read, so the device is never picked + // here from an inventory that cannot answer it. + if (hasLeaseDeviceSpec(options.flags)) { + 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.`, + ); + } + return { state: options.state, allocateBySpec: true }; + } // The service behind a proxy may create or choose the device only when it allocates, so without // an exact identity the request carries the selectors and the lease names the device. if (!options.flags.udid && !options.flags.serial && options.leaseBackend) { @@ -60,6 +77,10 @@ export async function resolveProxyLeaseState(options: { return await resolveProxyLeaseStateLocally(options); } +function hasLeaseDeviceSpec(flags: CliFlags): boolean { + return Object.keys(readLeaseDeviceSpec(flags)).length > 0; +} + async function resolveProxyLeaseStateLocally(options: { client: AgentDeviceClient; state: RemoteConnectionState; @@ -87,6 +108,12 @@ async function resolveProxyLeaseStateLocally(options: { * lease unset, so that lease is released and the device is picked here and allocated by its key, as * before. Every lease this client allocates therefore names its device; a renewed lease that names * 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 + * 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. */ export async function bindLeaseNamedDevice(options: { client: AgentDeviceClient; @@ -98,29 +125,39 @@ export async function bindLeaseNamedDevice(options: { }): Promise<{ state: RemoteConnectionState; materialized: MaterializedLease }> { const { client, flags, materialized } = options; const { lease, acquired } = materialized; + const spec = readLeaseDeviceSpec(flags); + const specKeys = namedLeaseDeviceSpecKeys(spec); const leased = parseConnectionDeviceKey(lease.deviceKey); + if (acquired && specKeys.length > 0 && (!lease.device || !leased)) { + throw await releaseUnhonouredSpecLease(client, options.state, lease, specKeys); + } if (leased) { - if (!acquired) await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); + if (!acquired) { + assertLeaseSpecMatchesRequest(options.state, spec, leased); + await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); + } applyDeviceScopeSelector(flags, leased); + delete flags.osVersion; + delete flags.imageTag; + delete flags.physical; return { state: { ...options.state, deviceKey: buildConnectionDeviceKey(leased), platform: leased.platform, target: options.state.target ?? leased.target, + ...(acquired + ? { + deviceSpec: specKeys.length > 0 ? spec : undefined, + leaseDevice: lease.device, + } + : {}), }, materialized, }; } if (!acquired) throw unboundLeaseError(options.state, lease); - await client.leases.release({ - tenant: options.state.tenant, - runId: options.state.runId, - leaseId: lease.leaseId, - leaseBackend: lease.backend, - ...mergeRemoteConnectionRequestMetadata(lease, options.state), - deviceKey: lease.deviceKey, - }); + await releaseLease(client, options.state, lease); const resolved = await resolveProxyLeaseStateLocally(options); return { state: resolved.state, @@ -128,6 +165,73 @@ export async function bindLeaseNamedDevice(options: { }; } +async function releaseLease( + client: AgentDeviceClient, + state: RemoteConnectionState, + lease: Lease, +): Promise<{ released: boolean }> { + return await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: lease.leaseId, + leaseBackend: lease.backend, + ...mergeRemoteConnectionRequestMetadata(lease, state), + deviceKey: lease.deviceKey, + }); +} + +/** + * Releases a new lease whose backend did not confirm the device spec, and returns the refusal. A + * release that fails is reported in the refusal, which still names the lease, rather than raised in + * its place: the lease then lapses after its inactivity window. + */ +async function releaseUnhonouredSpecLease( + client: AgentDeviceClient, + state: RemoteConnectionState, + lease: Lease, + keys: readonly string[], +): Promise { + let release: Record; + try { + release = { released: (await releaseLease(client, state, lease)).released }; + } catch (error) { + release = { released: false, releaseError: errorMessage(error) }; + } + return deviceSpecUnsupportedError( + keys, + 'The proxy backend leased a device without confirming the requested device spec, so the lease was released.', + { session: state.session, leaseId: lease.leaseId, ...release }, + ); +} + +/** + * A renewed lease serves a request only when every spec key the request names equals the one the + * lease was allocated by. A key the request leaves out keeps the lease's value. + */ +function assertLeaseSpecMatchesRequest( + state: RemoteConnectionState, + requested: LeaseDeviceSpec, + leased: ConnectionDeviceScope, +): void { + const bound = state.deviceSpec ?? {}; + const conflicting = namedLeaseDeviceSpecKeys(requested).filter( + (key) => requested[key] !== bound[key], + ); + if (conflicting.length === 0) return; + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a device leased by a different device spec. Re-run connect --force to replace it.', + { + session: state.session, + deviceKey: buildConnectionDeviceKey(leased), + keys: conflicting, + deviceSpec: bound, + requestedDeviceSpec: requested, + reason: 'CONNECTION_DEVICE_CONFLICT', + }, + ); +} + function unboundLeaseError(state: RemoteConnectionState, lease: Lease): AppError { return new AppError( 'UNAUTHORIZED', diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 609b223ef8..373be442d1 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,5 +1,6 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CliFlags } from '@agent-device/contracts/command'; +import type { LeaseDevice } from '@agent-device/contracts/client'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { CliCommandName } from '@agent-device/command-registry/catalog'; import type { CommandProgressState } from '../../commands/command-progress.ts'; @@ -15,6 +16,8 @@ export type ClientCommandParams = { replayTestReporterRuntime?: ReplayTestReporterRuntime; /** Progress this run's transport already rendered to stderr, read by the output formatters. */ commandProgress?: CommandProgressState; + /** The device a proxy backend granted for the lease's device spec; `open` reports it. */ + leaseDevice?: LeaseDevice; }; /** diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 14220317d1..0845fc4d4b 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -34,6 +34,7 @@ export async function tryRunClientBackedCommand(params: { debug?: boolean; replayTestReporterRuntime?: ClientCommandParams['replayTestReporterRuntime']; commandProgress?: ClientCommandParams['commandProgress']; + leaseDevice?: ClientCommandParams['leaseDevice']; }): Promise { const flags = { ...params.flags }; const loadDedicatedHandler = diff --git a/src/cli/connection/profile-fields.ts b/src/cli/connection/profile-fields.ts index eb3a92931c..1848d6dcbd 100644 --- a/src/cli/connection/profile-fields.ts +++ b/src/cli/connection/profile-fields.ts @@ -9,7 +9,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; * re-signing). Read as a group because every hosted-provider profile carries the same set, and the * capability projection for them lives with the provider, not here. */ -export function readCloudDeviceFeatureProfileFields( +function readCloudDeviceFeatureProfileFields( flags: CliFlags, ): Pick< CloudProviderProfileFields, @@ -36,6 +36,24 @@ export function readCloudDeviceFeatureProfileFields( }; } +/** The hosted-provider device-feature fields and the AWS Device Farm fields a lease allocates with. */ +export function readHostedDeviceProfileFields( + flags: CliFlags, +): ReturnType & + Pick< + CloudProviderProfileFields, + 'awsProjectArn' | 'awsDeviceArn' | 'awsAppArn' | 'awsRegion' | 'awsInteractionMode' + > { + return { + ...readCloudDeviceFeatureProfileFields(flags), + awsProjectArn: flags.awsProjectArn, + awsDeviceArn: flags.awsDeviceArn, + awsAppArn: flags.awsAppArn, + awsRegion: flags.awsRegion, + awsInteractionMode: flags.awsInteractionMode, + }; +} + export function readMetroProfileFields(flags: CliFlags): RemoteConfigMetroOptions { return { metroProjectRoot: flags.metroProjectRoot, diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index fc3f90b5c6..d254069eaa 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -36,6 +36,9 @@ export function resolveProxyConnectProfile(options: { device: options.flags.device, udid: options.flags.udid, serial: options.flags.serial, + osVersion: options.flags.osVersion, + imageTag: options.flags.imageTag, + physical: options.flags.physical, iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, androidDeviceAllowlist: options.flags.androidDeviceAllowlist, session: options.flags.session, diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts index 2603546685..9d6ae05911 100644 --- a/src/client/lease-client.ts +++ b/src/client/lease-client.ts @@ -3,6 +3,7 @@ import type { InternalRequestOptions } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog'; import { isRecord } from '@agent-device/kernel/record'; +import { readLeaseDevice } from '@agent-device/contracts/lease-scope'; import type { AgentDeviceClient } from './client-types.ts'; import { readOptionalString, readRequiredString } from './client-normalizers.ts'; @@ -93,9 +94,14 @@ function normalizeLease(data: Record): Lease { leaseProvider: readOptionalString(rawLease, 'leaseProvider'), clientId: readOptionalString(rawLease, 'clientId'), deviceKey: readOptionalString(rawLease, 'deviceKey'), + ...withLeaseDevice(readLeaseDevice(rawLease.device)), ...(rawLease.retainOnClose === true ? { retainOnClose: true } : {}), createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, }; } + +function withLeaseDevice(device: Lease['device']): Pick { + return device ? { device } : {}; +} diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 909b6fdbd0..c139a6a385 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -57,6 +57,9 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { iosXctestDerivedDataPath: options.iosXctestDerivedDataPath, iosXctestEnvDir: options.iosXctestEnvDir, androidDeviceAllowlist: options.androidDeviceAllowlist, + osVersion: options.osVersion, + imageTag: options.imageTag, + physical: options.physical, surface: options.surface, activity: options.activity, launchConsole: options.launchConsole, diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 43c56e5efd..b0632060d8 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -15,8 +15,12 @@ import { stringSchema, } from '../command-input.ts'; import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; +import { leaseDeviceSpecFields, leaseDeviceSpecInputFromFlags } from './lease-device-spec.ts'; import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; -import { METRO_RELOAD_FLAGS } from '@agent-device/command-registry/flag-groups'; +import { + LEASE_DEVICE_SPEC_FLAGS, + METRO_RELOAD_FLAGS, +} from '@agent-device/command-registry/flag-groups'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { withCommandRuntimeHints } from '../runtime-hints.ts'; @@ -79,6 +83,7 @@ const openCommandMetadata = defineFieldCommandMetadata( launchUrl: stringField( 'Launch URL for the opened app; does not select the server used by metro reload.', ), + ...leaseDeviceSpecFields, }, ); @@ -130,6 +135,7 @@ const openCliSchema = { 'surface', ...METRO_RELOAD_FLAGS, 'launchUrl', + ...LEASE_DEVICE_SPEC_FLAGS, ], } as const satisfies CommandSchemaOverride; @@ -163,6 +169,7 @@ const openCliReader: CliReader = (positionals, flags) => ({ metroPort: flags.metroPort, bundleUrl: flags.bundleUrl, launchUrl: flags.launchUrl, + ...leaseDeviceSpecInputFromFlags(flags), }); const closeCliReader: CliReader = (positionals, flags) => ({ diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 9c5b0e21a1..7c2e0ca297 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -2,6 +2,7 @@ import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '@agent-device/command-regist import type { DaemonInstallSource } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import type { CommandSchemaOverride } from '@agent-device/command-registry/command-schema'; +import { LEASE_DEVICE_SPEC_FLAGS } from '@agent-device/command-registry/flag-groups'; import { AppError } from '@agent-device/kernel/errors'; import { parseGitHubActionsArtifactInstallSourceSpec } from '@agent-device/provision-kit/install-source-config'; import { @@ -23,6 +24,7 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { managementCliOutputFormatters } from './output.ts'; +import { leaseDeviceSpecFields, leaseDeviceSpecInputFromFlags } from './lease-device-spec.ts'; const installCommandMetadata = defineFieldCommandMetadata( 'install', @@ -30,6 +32,7 @@ const installCommandMetadata = defineFieldCommandMetadata( { app: stringField('Optional app identifier hint.'), appPath: requiredField(stringField('Path to app binary.')), + ...leaseDeviceSpecFields, }, ); @@ -39,6 +42,7 @@ const reinstallCommandMetadata = defineFieldCommandMetadata( { app: requiredField(stringField()), appPath: requiredField(stringField('Path to app binary.')), + ...leaseDeviceSpecFields, }, ); @@ -47,6 +51,7 @@ const uninstallCommandMetadata = defineFieldCommandMetadata( 'Remove an installed app from the selected device. A package name or bundle id that is not installed succeeds with wasInstalled: false.', { app: requiredField(stringField('Package name, bundle id, or installed app name.')), + ...leaseDeviceSpecFields, }, ); @@ -66,14 +71,17 @@ const installCliSchema = { usageOverride: 'install | install ', listUsageOverride: 'install ', positionalArgs: ['appOrPath', 'path?'], + allowedFlags: LEASE_DEVICE_SPEC_FLAGS, } as const satisfies CommandSchemaOverride; const reinstallCliSchema = { positionalArgs: ['app', 'path'], + allowedFlags: LEASE_DEVICE_SPEC_FLAGS, } as const satisfies CommandSchemaOverride; const uninstallCliSchema = { positionalArgs: ['app'], + allowedFlags: LEASE_DEVICE_SPEC_FLAGS, } as const satisfies CommandSchemaOverride; const installFromSourceCliSchema = { @@ -93,6 +101,7 @@ const reinstallCliReader: CliReader = (positionals, flags) => const uninstallCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), app: requiredString(positionals[0], 'uninstall requires app'), + ...leaseDeviceSpecInputFromFlags(flags), }); const installFromSourceCliReader: CliReader = (positionals, flags) => ({ @@ -187,6 +196,7 @@ function installInputFromCli(positionals: string[], flags: CliFlags): Record { + return readLeaseDeviceSpec(flags); +} diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index c1121eab19..7de811abe8 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -559,6 +559,12 @@ Direct proxy flow for a remote Mac/simulator: agent-device close agent-device disconnect +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 join them; the backend validates their values (for example, Android --os is an API level). connect proxy stores them 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 the three keys with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. A lease returned without lease.device is released, and the command fails with the same reason. 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. agent-device takeover --session remote-session diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 8c815ad47a..bca09df59b 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -4,6 +4,7 @@ import type { LocalCliCommandName } from '@agent-device/command-registry/catalog import type { CommandSchema } from '@agent-device/command-registry/command-schema'; import { COMMON_COMMAND_SUPPORTED_FLAG_KEYS, + LEASE_DEVICE_SPEC_FLAGS, METRO_PREPARE_FLAGS, } from '@agent-device/command-registry/flag-groups'; @@ -111,6 +112,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'awsAppArn', 'awsRegion', 'awsInteractionMode', + ...LEASE_DEVICE_SPEC_FLAGS, 'force', 'noLogin', ], diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index 2a8ec92267..a8c49fdce7 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -189,3 +189,33 @@ test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { assert.equal(cause.code?.length, 400); assert.match(cause.code ?? '', /$/); }); + +test('lease allocation carries the device spec a proxy backend picks by', () => { + const payload = buildHttpRpcPayload( + { + token: 'daemon-token', + session: 'adc-proxy', + command: 'lease_allocate', + positionals: [], + flags: { + platform: 'android', + osVersion: '>=34', + imageTag: 'google_apis_playstore', + physical: true, + }, + meta: { requestId: 'lease-req', tenantId: 'proxy', runId: 'proxy-run' }, + }, + { includeTokenParam: false }, + ); + + assert.equal(payload.method, 'agent_device.lease.allocate'); + assert.deepEqual(payload.params, { + session: 'adc-proxy', + tenantId: 'proxy', + runId: 'proxy-run', + platform: 'android', + osVersion: '>=34', + imageTag: 'google_apis_playstore', + physical: true, + }); +}); diff --git a/src/mcp/__tests__/command-tools-operator-inputs.test.ts b/src/mcp/__tests__/command-tools-operator-inputs.test.ts index 934bc80fea..cb22797ab1 100644 --- a/src/mcp/__tests__/command-tools-operator-inputs.test.ts +++ b/src/mcp/__tests__/command-tools-operator-inputs.test.ts @@ -258,3 +258,15 @@ test('MCP neither advertises nor admits the CLI-only fill textStdin marker', asy assert.match(result.content[0]?.text ?? '', /textStdin is not accepted as a tool argument/); assert.deepEqual(calls, [], 'a refused operator input must never reach the command route'); }); + +// The proxy lease device spec names which device THIS call leases, so the model may write it. +test('MCP open, install, reinstall and uninstall advertise the proxy lease device spec', () => { + for (const name of ['open', 'install', 'reinstall', 'uninstall']) { + 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']) { + 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 f95df6ef0f..03affbe94d 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -57,6 +57,26 @@ Do not put the proxy endpoint, token, tenant, or provider fields in `./agent-dev configuration accepts only project-safe automation defaults. Set the endpoint and token with `connect proxy`, user 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. `connect proxy` stores them as defaults like `--platform` and `--device`. agent-device does not check the values; the service does (for example, Android `--os` is an API level). `--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 +``` + +| Flag | Key on `agent_device.lease.allocate` | Type | +| --- | --- | --- | +| `--os ` | `osVersion` | string | +| `--image-tag ` | `imageTag` | string | +| `--physical` | `physical` | `true` | + +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 them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. 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. + ## Human takeover With a remote device already leased by `open`, pause mutations through the same connection: From be313816ac89b03b3cfd39d0b0bed22d4d472409 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 13:50:47 +0200 Subject: [PATCH 2/7] fix(proxy): tie the recorded device spec to the lease it was allocated with --- .../src/flag-definitions-target.ts | 6 +- .../remote/src/remote-connection-state.ts | 12 ++-- ...t-device-client-lease-device-spec.test.ts} | 0 .../proxy-lease-device-spec.test.ts} | 59 +++++++++++++++---- src/cli/commands/connection-runtime.ts | 4 +- src/cli/commands/proxy-lease-device.ts | 24 +++++--- 6 files changed, 76 insertions(+), 29 deletions(-) rename src/__tests__/{lease-device-spec-refusal.test.ts => agent-device-client-lease-device-spec.test.ts} (100%) rename src/{__tests__/remote-connection-proxy-device-spec.test.ts => cli/commands/__tests__/proxy-lease-device-spec.test.ts} (83%) diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 26390c1d05..d97e898022 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -60,7 +60,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageDescription: 'Proxy lease: OS version or range the backend picks the device by (Android: API level); refused without such a backend', inputDescription: - 'OS version or range for the device a proxy backend leases, for example 18.6.2 or ">=26" (Android: API level). The backend validates it; a daemon without such a backend refuses it with reason device-spec-unsupported.', + 'OS version or range for the device a proxy backend leases, for example 18.6.2 or ">=26" (Android: API level). 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, }, @@ -72,7 +72,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageDescription: 'Proxy lease: Android system image tag the backend picks the device by; refused without such a backend', inputDescription: - 'Android system image tag for the device a proxy backend leases, for example google_apis_playstore. A daemon without such a backend refuses it with reason device-spec-unsupported.', + 'Android system image tag for the device a proxy backend leases, for example google_apis_playstore. 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, }, @@ -84,7 +84,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageDescription: 'Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend', inputDescription: - 'Ask a proxy backend to lease a physical device instead of a simulator or emulator. A daemon without such a backend refuses it with reason device-spec-unsupported.', + 'Ask a proxy backend to lease a physical device instead of a simulator or emulator. 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, }, diff --git a/packages/remote/src/remote-connection-state.ts b/packages/remote/src/remote-connection-state.ts index fb4ad441b5..9e5e460d30 100644 --- a/packages/remote/src/remote-connection-state.ts +++ b/packages/remote/src/remote-connection-state.ts @@ -51,10 +51,11 @@ export type RemoteConnectionState = { clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; - /** The device spec the lease was allocated by; a later request naming another one conflicts. */ - deviceSpec?: LeaseDeviceSpec; - /** The device facts the proxy backend granted for {@link deviceSpec}. */ - leaseDevice?: LeaseDevice; + /** + * The device spec a proxy lease was allocated by and the device its backend granted. It describes + * only the lease `leaseId` names: once that lease ends or is replaced, it describes nothing. + */ + leaseDeviceSpec?: { leaseId: string; spec: LeaseDeviceSpec; device?: LeaseDevice }; runtime?: SessionRuntimeHints; metro?: { projectRoot: string; @@ -504,8 +505,7 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'clientId', ]) && isOptionalRemoteConnectionDaemonState(record.daemon) && - isOptionalRecord(record.deviceSpec) && - isOptionalRecord(record.leaseDevice) + isOptionalRecord(record.leaseDeviceSpec) ); } diff --git a/src/__tests__/lease-device-spec-refusal.test.ts b/src/__tests__/agent-device-client-lease-device-spec.test.ts similarity index 100% rename from src/__tests__/lease-device-spec-refusal.test.ts rename to src/__tests__/agent-device-client-lease-device-spec.test.ts diff --git a/src/__tests__/remote-connection-proxy-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts similarity index 83% rename from src/__tests__/remote-connection-proxy-device-spec.test.ts rename to src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index 9cc8c3b7bb..9c3c1cea61 100644 --- a/src/__tests__/remote-connection-proxy-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -11,20 +11,20 @@ import { type LeaseReleaseRequest, seedConnectionState, unexpectedCommandCall, -} from './remote-connection.fixtures.ts'; +} from '../../../__tests__/remote-connection.fixtures.ts'; vi.mock('@agent-device/react-native', async (importOriginal) => ({ ...(await importOriginal()), stopMetroTunnel: vi.fn(), })); -import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { materializeRemoteConnectionForCommand } from '../connection-runtime.ts'; import { readActiveConnectionState, readRemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; -import { connectCommand } from './test-utils/host-bound-commands.ts'; -import { withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; +import { connectCommand } from '../../../__tests__/test-utils/host-bound-commands.ts'; +import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; bindTestCommandHost(); @@ -36,7 +36,11 @@ const GRANTED_DEVICE: LeaseDevice = { function proxyWorkspace( prefix: string, - seed: { leaseId?: string; deviceKey?: string; deviceSpec?: LeaseDeviceSpec } = {}, + seed: { + leaseId?: string; + deviceKey?: string; + leaseDeviceSpec?: { leaseId: string; spec: LeaseDeviceSpec; device?: LeaseDevice }; + } = {}, ) { const workspace = connectionWorkspace(prefix); fs.writeFileSync( @@ -57,7 +61,7 @@ function proxyWorkspace( ? { leaseId: seed.leaseId, leaseBackend: 'ios-instance', platform: 'ios' } : {}), ...(seed.deviceKey ? { deviceKey: seed.deviceKey } : {}), - ...(seed.deviceSpec ? { deviceSpec: seed.deviceSpec } : {}), + ...(seed.leaseDeviceSpec ? { leaseDeviceSpec: seed.leaseDeviceSpec } : {}), }, }); const flags = { @@ -140,8 +144,11 @@ test('proxy open by device spec sends the spec on allocate without listing devic assert.equal(materialized.flags.physical, undefined); const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); assert.equal(state?.leaseId, 'lease-1'); - assert.deepEqual(state?.deviceSpec, { osVersion: '>=26', physical: true }); - assert.deepEqual(state?.leaseDevice, GRANTED_DEVICE); + assert.deepEqual(state?.leaseDeviceSpec, { + leaseId: 'lease-1', + spec: { osVersion: '>=26', physical: true }, + device: GRANTED_DEVICE, + }); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -210,7 +217,7 @@ test('a later open on a live spec lease is served when it names the same spec', const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-same-', { leaseId: 'lease-1', deviceKey: 'ios:mobile:SIM-REMOTE', - deviceSpec: { osVersion: '>=26' }, + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' }, device: GRANTED_DEVICE }, }); const heartbeats: string[] = []; @@ -224,6 +231,7 @@ test('a later open on a live spec lease is served when it names the same spec', assert.deepEqual(heartbeats, ['lease-1']); assert.equal(materialized.flags.udid, 'SIM-REMOTE'); assert.equal(materialized.flags.osVersion, undefined); + assert.deepEqual(materialized.leaseDevice, GRANTED_DEVICE); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -231,7 +239,7 @@ test('a later open naming a different --os on a live spec lease is refused with const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-conflict-', { leaseId: 'lease-1', deviceKey: 'ios:mobile:SIM-REMOTE', - deviceSpec: { osVersion: '>=26' }, + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' } }, }); await assert.rejects( @@ -249,7 +257,7 @@ test('a later open naming a different --os on a live spec lease is refused with ); const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); assert.equal(state?.leaseId, 'lease-1'); - assert.deepEqual(state?.deviceSpec, { osVersion: '>=26' }); + assert.deepEqual(state?.leaseDeviceSpec?.spec, { osVersion: '>=26' }); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -274,6 +282,35 @@ test('a spec named on a live lease allocated without one is refused with CONNECT fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('a spec recorded for an earlier lease neither serves nor describes the lease held now', async () => { + // lease-1 was allocated by spec and has ended; lease-2 was then allocated by --udid without one. + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-stale-', { + leaseId: 'lease-2', + deviceKey: 'ios:mobile:SIM-REMOTE', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' }, device: GRANTED_DEVICE }, + }); + + const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags, + client: renewingClient([]), + }); + assert.equal(materialized.leaseDevice, undefined); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '>=26' }, + client: renewingClient([]), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && error.details?.reason === 'CONNECTION_DEVICE_CONFLICT', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect proxy stores the device spec as profile defaults like platform and device', async () => { const { tempRoot, stateDir } = connectionWorkspace('agent-device-connect-proxy-spec-'); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 231b0ea479..3839d6291f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -43,6 +43,7 @@ import { readHostedDeviceProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; import { bindLeaseNamedDevice, + grantedLeaseDevice, resolveProxyLeaseState, type ResolvedLeaseState, } from './proxy-lease-device.ts'; @@ -73,7 +74,6 @@ export async function materializeRemoteConnectionForCommand(options: { flags: CliFlags; runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; - /** The device the proxy backend granted for the lease's device spec, when it named one. */ leaseDevice?: LeaseDevice; }> { const { command, flags, client, host } = options; @@ -204,7 +204,7 @@ export async function materializeRemoteConnectionForCommand(options: { }, runtime: nextRuntime, connection: buildRemoteConnectionRequestMetadata(nextState), - leaseDevice: nextState.leaseDevice, + leaseDevice: grantedLeaseDevice(nextState), }; } diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 6d941d1ba9..aa32ec29ca 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -18,7 +18,7 @@ import { namedLeaseDeviceSpecKeys, readLeaseDeviceSpec, } from '@agent-device/contracts/lease-scope'; -import type { LeaseDeviceSpec } from '@agent-device/contracts/client'; +import type { LeaseDevice, LeaseDeviceSpec } from '@agent-device/contracts/client'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; @@ -133,7 +133,7 @@ export async function bindLeaseNamedDevice(options: { } if (leased) { if (!acquired) { - assertLeaseSpecMatchesRequest(options.state, spec, leased); + assertLeaseSpecMatchesRequest(options.state, lease, spec, leased); await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); } applyDeviceScopeSelector(flags, leased); @@ -147,10 +147,7 @@ export async function bindLeaseNamedDevice(options: { platform: leased.platform, target: options.state.target ?? leased.target, ...(acquired - ? { - deviceSpec: specKeys.length > 0 ? spec : undefined, - leaseDevice: lease.device, - } + ? { leaseDeviceSpec: { leaseId: lease.leaseId, spec, device: lease.device } } : {}), }, materialized, @@ -210,10 +207,11 @@ async function releaseUnhonouredSpecLease( */ function assertLeaseSpecMatchesRequest( state: RemoteConnectionState, + lease: Lease, requested: LeaseDeviceSpec, leased: ConnectionDeviceScope, ): void { - const bound = state.deviceSpec ?? {}; + const bound = recordedLeaseDeviceSpec(state, lease.leaseId)?.spec ?? {}; const conflicting = namedLeaseDeviceSpecKeys(requested).filter( (key) => requested[key] !== bound[key], ); @@ -232,6 +230,18 @@ function assertLeaseSpecMatchesRequest( ); } +function recordedLeaseDeviceSpec( + state: RemoteConnectionState, + leaseId: string | undefined, +): RemoteConnectionState['leaseDeviceSpec'] { + return leaseId && state.leaseDeviceSpec?.leaseId === leaseId ? state.leaseDeviceSpec : undefined; +} + +/** The device a proxy backend granted for the lease the connection holds now, if it named one. */ +export function grantedLeaseDevice(state: RemoteConnectionState): LeaseDevice | undefined { + return recordedLeaseDeviceSpec(state, state.leaseId)?.device; +} + function unboundLeaseError(state: RemoteConnectionState, lease: Lease): AppError { return new AppError( 'UNAUTHORIZED', From 12705494885c9dc8aade488d8825426e15bb5a8a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 14:00:58 +0200 Subject: [PATCH 3/7] feat(proxy): drop --physical; refuse a renewed lease for another --udid/--serial; keep lease.device at any response level --- docs/adr/0007-remote-device-leases.md | 6 +-- .../src/flag-definitions-target.ts | 12 ------ packages/command-registry/src/flag-groups.ts | 3 +- .../src/__tests__/lease-scope.test.ts | 24 +++++------ packages/contracts/src/cli-flags.ts | 1 - packages/contracts/src/client-lease.ts | 3 -- packages/contracts/src/lease-scope.ts | 17 +++----- .../contracts/src/remote-config-fields.ts | 1 - packages/contracts/src/request-envelope.ts | 1 - packages/remote/src/remote-config-schema.ts | 1 - scripts/integration-progress-model.ts | 2 +- ...nt-device-client-lease-device-spec.test.ts | 4 +- .../agent-device-client-leases.test.ts | 5 +-- src/cli/commands/__tests__/generic.test.ts | 24 ++++++++++- .../__tests__/proxy-lease-device-spec.test.ts | 41 ++++++++++++++----- src/cli/commands/generic.ts | 15 +++---- src/cli/commands/proxy-lease-device.ts | 28 +++++++++++-- src/cli/connection/proxy-profile.ts | 1 - src/commands/command-flags.ts | 1 - src/commands/management/lease-device-spec.ts | 5 +-- src/commands/schema/cli-help.ts | 4 +- src/daemon-client/daemon-client-rpc.test.ts | 2 - .../command-tools-operator-inputs.test.ts | 4 +- website/docs/docs/remote-proxy.md | 8 ++-- 24 files changed, 117 insertions(+), 96 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index dde52a2e03..6cc9c90660 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -292,14 +292,14 @@ 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` and `imageTag` 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 any of the three keys, before admission and before a lease is recorded. +Thus, a daemon refuses every request that names either key, 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`. -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?`). `deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood. A backend older than the spec returns a lease without `lease.device`. The client then releases that lease and fails with the same reason. diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index d97e898022..82d9c029a0 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -76,18 +76,6 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: false, recorded: false, }, - { - key: 'physical', - names: ['--physical'], - type: 'boolean', - usageLabel: '--physical', - usageDescription: - 'Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend', - inputDescription: - 'Ask a proxy backend to lease a physical device instead of a simulator or emulator. 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: 'stale', names: ['--stale'], diff --git a/packages/command-registry/src/flag-groups.ts b/packages/command-registry/src/flag-groups.ts index 4249f9daff..75f8338435 100644 --- a/packages/command-registry/src/flag-groups.ts +++ b/packages/command-registry/src/flag-groups.ts @@ -1,3 +1,4 @@ +import { LEASE_DEVICE_SPEC_KEYS } from '@agent-device/contracts/lease-scope'; import type { FlagKey } from './flag-types.ts'; function flagKeys(...keys: TKeys): TKeys { @@ -34,7 +35,7 @@ export const METRO_PREPARE_FLAGS = flagKeys( export const METRO_RELOAD_FLAGS = flagKeys('metroHost', 'metroPort', 'bundleUrl'); // The device spec a proxy lease is allocated by; the commands that allocate a proxy lease take it. -export const LEASE_DEVICE_SPEC_FLAGS = flagKeys('osVersion', 'imageTag', 'physical'); +export const LEASE_DEVICE_SPEC_FLAGS = LEASE_DEVICE_SPEC_KEYS; export const REPEATED_TOUCH_FLAGS = flagKeys( 'count', 'intervalMs', diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index c1c7773ab3..24fc55d8bc 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -253,34 +253,30 @@ test('readLeaseAllocateProviderFlags carries every device-spec key a request nam platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', - physical: true, }), - { platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', physical: true }, + { platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore' }, ); // A malformed key still reaches the daemon, which refuses it rather than never seeing it. - assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, physical: false }), { + assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, imageTag: false }), { osVersion: 26, - physical: false, + imageTag: false, }); }); -test('readLeaseDeviceSpec names only a non-empty version or tag and physical: true', () => { - const padded = { osVersion: ' >=26 ', imageTag: '', physical: false }; +test('readLeaseDeviceSpec names only a non-empty version or tag', () => { + const padded = { osVersion: ' >=26 ', imageTag: '' }; assert.deepEqual(readLeaseDeviceSpec(padded), { osVersion: '>=26' }); - assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis', physical: true }), { - imageTag: 'google_apis', - physical: true, - }); + assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis' }), { imageTag: 'google_apis' }); assert.deepEqual(readLeaseDeviceSpec({}), {}); }); test('readLeaseDevice reads the granted facts and rejects an echo missing one', () => { - const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; + const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' }; assert.deepEqual(readLeaseDevice(pixel), pixel); - const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0', physical: true }; + const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0' }; assert.deepEqual(readLeaseDevice(iphone), iphone); - assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro', osVersion: '26.0' }), undefined); - assert.equal(readLeaseDevice({ osVersion: '26.0', physical: true }), undefined); + assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro' }), undefined); + assert.equal(readLeaseDevice({ osVersion: '26.0' }), undefined); assert.equal(readLeaseDevice(undefined), undefined); }); diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index dcf3103b5a..4b5c35d62e 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -65,7 +65,6 @@ export type CliFlags = CloudProviderProfileFields & // that names any of them (`LEASE_DEVICE_SPEC_KEYS`). osVersion?: string; imageTag?: string; - physical?: boolean; stale?: boolean; iosSimulatorDeviceSet?: string; iosXctestrunFile?: string; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index eb66300acd..f3aa6f9f62 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -36,8 +36,6 @@ export type LeaseDeviceSpec = { osVersion?: string; /** Android system image tag, for example `google_apis_playstore`. */ imageTag?: string; - /** `true` asks for a physical device instead of a simulator or emulator. */ - physical?: boolean; }; /** The device facts the backend granted for a {@link LeaseDeviceSpec}. */ @@ -46,7 +44,6 @@ export type LeaseDevice = { /** The exact OS version granted. */ osVersion: string; imageTag?: string; - physical: boolean; }; /** diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 81e5ba74b7..8be68de559 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -273,13 +273,12 @@ type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[n export const LEASE_DEVICE_SPEC_KEYS = [ 'osVersion', 'imageTag', - 'physical', ] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[]; type LeaseDeviceSpecKey = (typeof LEASE_DEVICE_SPEC_KEYS)[number]; /** The typed reason a request naming a device spec is refused with. */ -export const DEVICE_SPEC_UNSUPPORTED_REASON = 'device-spec-unsupported'; +const DEVICE_SPEC_UNSUPPORTED_REASON = 'device-spec-unsupported'; // A Cloud provider profile field added without joining the projection would be dropped from the // envelope and fail `prepareSession` on the remote daemon — this makes that omission fail to build. @@ -319,19 +318,14 @@ export function namedLeaseDeviceSpecKeys( return LEASE_DEVICE_SPEC_KEYS.filter((key) => source?.[key] !== undefined); } -/** - * The device spec a CLI flags bag or profile asks for. An empty string names no version or tag, - * and only `physical: true` asks for a physical device. - */ +/** The device spec a CLI flags bag or profile asks for. An empty string names no version or tag. */ export function readLeaseDeviceSpec(source: { osVersion?: unknown; imageTag?: unknown; - physical?: unknown; }): LeaseDeviceSpec { return stripUndefined({ osVersion: nonEmptyString(source.osVersion), imageTag: nonEmptyString(source.imageTag), - physical: source.physical === true ? true : undefined, }); } @@ -341,27 +335,26 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined { const record = value as Record; const model = nonEmptyString(record.model); const osVersion = nonEmptyString(record.osVersion); - if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined; + if (!model || !osVersion) return undefined; const imageTag = nonEmptyString(record.imageTag); return { model, osVersion, ...(imageTag ? { imageTag } : {}), - physical: record.physical, }; } /** 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 or image tag.', 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 and --image-tag, or connect through a proxy whose backend picks devices by spec.', }); } diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 182dcc5b80..67b48136cb 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -142,7 +142,6 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & serial?: string; osVersion?: string; imageTag?: string; - physical?: boolean; iosSimulatorDeviceSet?: string; androidDeviceAllowlist?: string; session?: string; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index 98b4661977..bffe239a13 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -68,7 +68,6 @@ export type InternalRequestOptions = AgentDeviceClientConfig & surface?: SessionSurface; osVersion?: string; imageTag?: string; - physical?: boolean; activity?: string; launchConsole?: string; launchArgs?: string[]; diff --git a/packages/remote/src/remote-config-schema.ts b/packages/remote/src/remote-config-schema.ts index 234bbb8509..8a97ea80ad 100644 --- a/packages/remote/src/remote-config-schema.ts +++ b/packages/remote/src/remote-config-schema.ts @@ -50,7 +50,6 @@ 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: 'physical', type: 'boolean', env: false }, { key: 'iosSimulatorDeviceSet', type: 'string', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 1ecbfd6824..ffacf58619 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'], }, { name: 'cloud artifact provider lookup', diff --git a/src/__tests__/agent-device-client-lease-device-spec.test.ts b/src/__tests__/agent-device-client-lease-device-spec.test.ts index f8673a08cf..637b150e30 100644 --- a/src/__tests__/agent-device-client-lease-device-spec.test.ts +++ b/src/__tests__/agent-device-client-lease-device-spec.test.ts @@ -52,9 +52,9 @@ test('a daemon refuses a lease allocate naming a device spec and records no leas leaseBackend: 'ios-instance', platform: 'ios', osVersion: '>=26', - physical: true, + imageTag: 'iphone', }), - isDeviceSpecRefusal(['osVersion', 'physical']), + isDeviceSpecRefusal(['osVersion', 'imageTag']), ); await assertNoLeaseRecorded(client); // The same allocate without the spec is recorded, so the heartbeat above proves the refusal. diff --git a/src/__tests__/agent-device-client-leases.test.ts b/src/__tests__/agent-device-client-leases.test.ts index b295b98f0e..30e7dd21d6 100644 --- a/src/__tests__/agent-device-client-leases.test.ts +++ b/src/__tests__/agent-device-client-leases.test.ts @@ -44,7 +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', physical: false }, + device: { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' }, }, }, })); @@ -58,16 +58,13 @@ test('lease allocation sends the device spec and returns the device the backend platform: 'android', osVersion: '34', imageTag: 'google_apis', - 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?.physical, true); assert.deepEqual(lease.device, { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', - physical: false, }); }); diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index 086b4ab766..6032eb0728 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -123,7 +123,7 @@ test('open --json prints the device a proxy backend granted under lease.device', }, }, ); - const device = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; + const device = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' }; const json = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ @@ -149,5 +149,25 @@ test('open --json prints the device a proxy backend granted under lease.device', leaseDevice: device, }), ); - assert.match(human, /Leased device: Pixel 9, 34, google_apis, virtual/); + assert.match(human, /Leased device: Pixel 9, 34, google_apis/); +}); + +test('open --json at a non-default response level still prints lease.device', async () => { + const client = createAgentDeviceClient( + { session: 'qa', responseLevel: 'digest' }, + { transport: async (): Promise => ({ ok: true, data: { session: 'qa' } }) }, + ); + const device = { model: 'iPhone 17 Pro', osVersion: '26.1' }; + + const json = await withCapturedStdoutAsync(() => + runGenericClientBackedCommand({ + host: createTestHostEnvironment(), + command: 'open' as ClientBackedCliCommandName, + positionals: ['MyApp'], + flags: { json: true, responseLevel: 'digest' } as CliFlags, + client, + leaseDevice: device, + }), + ); + assert.deepEqual((JSON.parse(json) as { data: Record }).data.lease, { device }); }); 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 9c3c1cea61..7f826199d9 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -31,7 +31,6 @@ bindTestCommandHost(); const GRANTED_DEVICE: LeaseDevice = { model: 'iPhone 17 Pro', osVersion: '26.1', - physical: false, }; function proxyWorkspace( @@ -119,7 +118,7 @@ test('proxy open by device spec sends the spec on allocate without listing devic const materialized = await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'open', - flags: { ...flags, device: 'iPhone 17 Pro', osVersion: '>=26', physical: true }, + flags: { ...flags, device: 'iPhone 17 Pro', osVersion: '>=26', imageTag: 'iphone' }, client: createTestClient({ listDevices: unexpectedCommandCall, allocate: async (request) => { @@ -135,18 +134,17 @@ test('proxy open by device spec sends the spec on allocate without listing devic 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.equal(requests[0]?.imageTag, 'iphone'); 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); + assert.equal(materialized.flags.imageTag, 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: { osVersion: '>=26', imageTag: 'iphone' }, device: GRANTED_DEVICE, }); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -194,7 +192,7 @@ test('a failed release of an unconfirmed spec lease still fails the command with materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'open', - flags: { ...flags, physical: true }, + flags: { ...flags, imageTag: 'google_apis' }, client: createTestClient({ listDevices: unexpectedCommandCall, allocate: async (request) => grantedLease(request), @@ -271,13 +269,36 @@ test('a spec named on a live lease allocated without one is refused with CONNECT materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'install', - flags: { ...flags, physical: true }, + flags: { ...flags, imageTag: 'google_apis' }, client: renewingClient([]), }), (error: AppError) => error.code === 'INVALID_ARGS' && error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && - JSON.stringify(error.details?.keys) === '["physical"]', + JSON.stringify(error.details?.keys) === '["imageTag"]', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a later open naming another --udid beside the spec on a live spec lease is refused', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-udid-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' } }, + }); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '>=26', udid: 'SIM-OTHER' }, + client: renewingClient([]), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && + error.details?.requestedDevice === 'SIM-OTHER' && + error.details?.deviceKey === 'ios:mobile:SIM-REMOTE', ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -326,7 +347,6 @@ test('connect proxy stores the device spec as profile defaults like platform and platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', - physical: true, }, client: createTestClient(), }); @@ -341,6 +361,5 @@ 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.physical, true); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 1776397413..44fcb9d2cc 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -42,7 +42,11 @@ export async function runGenericClientBackedCommand({ // they serialize the default shape and drop the digest fields. Emit the leveled // payload verbatim instead. if (isNonDefaultResponseLevel(flags.responseLevel)) { - await writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); + const leveled = + command === 'open' && leaseDevice && isRecord(result) + ? { ...result, lease: { device: leaseDevice } } + : result; + await writeCommandOutput(flags, leveled, () => JSON.stringify(leveled, null, 2)); return true; } if (cliOutput) { @@ -95,12 +99,9 @@ async function writeGenericCliOutput( /** Adds the device a proxy backend granted for the lease's device spec to the `open` output. */ function withLeaseDevice(output: CliOutput, device: LeaseDevice): CliOutput { const lease = { lease: { device } }; - const facts = [ - device.model, - device.osVersion, - device.imageTag, - device.physical ? 'physical' : 'virtual', - ].filter((fact): fact is string => Boolean(fact)); + const facts = [device.model, device.osVersion, device.imageTag].filter((fact): fact is string => + Boolean(fact), + ); const leasedLine = `Leased device: ${facts.join(', ')}`; return { ...output, diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index aa32ec29ca..3ace48df44 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -41,8 +41,8 @@ export type ResolvedLeaseState = { }; /** - * Decides which device a proxy lease is for. A request with an exact identity picks the device here - * and points `flags` at it; any other allocating request is allocated by spec. + * Decides which device a proxy lease is for. A request with an exact identity and no device spec + * picks the device here and points `flags` at it; any other allocating request is allocated by spec. */ export async function resolveProxyLeaseState(options: { command: string; @@ -64,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 or --image-tag requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, ); } return { state: options.state, allocateBySpec: true }; @@ -134,12 +134,12 @@ export async function bindLeaseNamedDevice(options: { if (leased) { if (!acquired) { assertLeaseSpecMatchesRequest(options.state, lease, spec, leased); + assertLeasedIdentityMatchesRequest(options.state, flags, leased); await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); } applyDeviceScopeSelector(flags, leased); delete flags.osVersion; delete flags.imageTag; - delete flags.physical; return { state: { ...options.state, @@ -230,6 +230,26 @@ function assertLeaseSpecMatchesRequest( ); } +/** A renewed lease serves a request naming `--udid` or `--serial` only when it leases that device. */ +function assertLeasedIdentityMatchesRequest( + state: RemoteConnectionState, + flags: CliFlags, + leased: ConnectionDeviceScope, +): void { + const requested = flags.udid ?? flags.serial; + if (requested === undefined || requested === leased.id) return; + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a different device. Re-run connect --force to replace it.', + { + session: state.session, + deviceKey: buildConnectionDeviceKey(leased), + requestedDevice: requested, + reason: 'CONNECTION_DEVICE_CONFLICT', + }, + ); +} + function recordedLeaseDeviceSpec( state: RemoteConnectionState, leaseId: string | undefined, diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index d254069eaa..e6b9c9654c 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -38,7 +38,6 @@ export function resolveProxyConnectProfile(options: { serial: options.flags.serial, osVersion: options.flags.osVersion, imageTag: options.flags.imageTag, - physical: options.flags.physical, iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, androidDeviceAllowlist: options.flags.androidDeviceAllowlist, session: options.flags.session, diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index c139a6a385..9612e5af74 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -59,7 +59,6 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { androidDeviceAllowlist: options.androidDeviceAllowlist, osVersion: options.osVersion, imageTag: options.imageTag, - physical: options.physical, surface: options.surface, activity: options.activity, launchConsole: options.launchConsole, diff --git a/src/commands/management/lease-device-spec.ts b/src/commands/management/lease-device-spec.ts index 4cbafd3950..0ed27ef8ca 100644 --- a/src/commands/management/lease-device-spec.ts +++ b/src/commands/management/lease-device-spec.ts @@ -10,12 +10,11 @@ import { optionField } from '../command-input.ts'; export const leaseDeviceSpecFields = { osVersion: optionField('osVersion'), imageTag: optionField('imageTag'), - physical: optionField('physical'), }; -/** The spec keys the flags name; an empty version or tag and `physical: false` name none. */ +/** The spec keys the flags name; an empty version or tag names none. */ export function leaseDeviceSpecInputFromFlags( flags: CliFlags, -): Pick { +): Pick { return readLeaseDeviceSpec(flags); } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 7de811abe8..a24ad9a9f8 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -560,10 +560,10 @@ Direct proxy flow for a remote Mac/simulator: agent-device disconnect 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 join them; the backend validates their values (for example, Android --os is an API level). connect proxy stores them 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 and --image-tag join them; the backend validates their values (for example, Android --os is an API level). connect proxy stores them 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 the three keys with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. A lease returned without lease.device is released, and the command fails with the same reason. 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); open --json prints it. A daemon, local or behind agent-device proxy, refuses both keys with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. A lease returned without lease.device is released, and the command fails with the same reason. 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/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index a8c49fdce7..57c8f4cdf0 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -201,7 +201,6 @@ test('lease allocation carries the device spec a proxy backend picks by', () => platform: 'android', osVersion: '>=34', imageTag: 'google_apis_playstore', - physical: true, }, meta: { requestId: 'lease-req', tenantId: 'proxy', runId: 'proxy-run' }, }, @@ -216,6 +215,5 @@ test('lease allocation carries the device spec a proxy backend picks by', () => platform: 'android', osVersion: '>=34', imageTag: 'google_apis_playstore', - physical: true, }); }); diff --git a/src/mcp/__tests__/command-tools-operator-inputs.test.ts b/src/mcp/__tests__/command-tools-operator-inputs.test.ts index cb22797ab1..26d7153217 100644 --- a/src/mcp/__tests__/command-tools-operator-inputs.test.ts +++ b/src/mcp/__tests__/command-tools-operator-inputs.test.ts @@ -259,13 +259,13 @@ test('MCP neither advertises nor admits the CLI-only fill textStdin marker', asy assert.deepEqual(calls, [], 'a refused operator input must never reach the command route'); }); -// The proxy lease device spec names which device THIS call leases, so the model may write it. +// The proxy lease device spec is not operator infrastructure, so the schema offers it (issue #3864). test('MCP open, install, reinstall and uninstall advertise the proxy lease device spec', () => { for (const name of ['open', 'install', 'reinstall', 'uninstall']) { 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 ['osVersion', 'imageTag']) { 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 03affbe94d..6aeb15c66a 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -59,23 +59,21 @@ 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. `connect proxy` stores them as defaults like `--platform` and `--device`. agent-device does not check the values; the service does (for example, Android `--os` is an API level). `--target` stays the device class selector. +A service in front of the daemon, such as simlock, can also pick the device by OS version and Android image tag. Pass `--os ` or `--image-tag ` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. `connect proxy` stores them as defaults like `--platform` and `--device`. agent-device does not check the values; the service does (for example, Android `--os` is an API level). `--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 ``` | Flag | Key on `agent_device.lease.allocate` | Type | | --- | --- | --- | | `--os ` | `osVersion` | string | | `--image-tag ` | `imageTag` | string | -| `--physical` | `physical` | `true` | -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), and `imageTag` when one applies. `open --json` prints it under `lease.device`. -The daemon itself cannot pick a device by these keys, so it refuses them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. 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 them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. While a lease is active, a later command that names a different value for either key 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. ## Human takeover From 4f94d93e8d88b13dc953c427434f6576c6a23e63 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 14:09:38 +0200 Subject: [PATCH 4/7] fix(proxy): send the spec only on a by-spec allocate, refuse a new spec lease for another --udid/--serial, keep the spec out of the environment --- .../__tests__/proxy-lease-device-spec.test.ts | 61 +++++++++++++++++++ src/cli/commands/connection-runtime.ts | 2 +- src/cli/commands/proxy-lease-device.ts | 19 ++++-- src/commands/schema/option-schema.ts | 3 + 4 files changed, 78 insertions(+), 7 deletions(-) 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 7f826199d9..b0180c12f4 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -303,6 +303,67 @@ test('a later open naming another --udid beside the spec on a live spec lease is fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('a new spec lease for another device than the requested --udid is released and refused', async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-device-spec-udid-new-'); + const releases: LeaseReleaseRequest[] = []; + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, osVersion: '>=26', udid: 'SIM-OTHER' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => grantedLease(request, { device: GRANTED_DEVICE }), + release: async (request) => { + releases.push(request); + return { released: true }; + }, + }), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && + error.details?.requestedDevice === 'SIM-OTHER', + ); + assert.deepEqual( + releases.map((release) => release.leaseId), + ['lease-1'], + ); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a lease re-allocated by its stored device for a non-allocating command does not carry the spec', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-snapshot-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' }, device: GRANTED_DEVICE }, + }); + const requests: LeaseAllocateRequest[] = []; + + await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'snapshot', + flags: { ...flags, osVersion: '>=26' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + heartbeat: async () => { + throw new AppError('UNAUTHORIZED', 'lease expired', { reason: 'LEASE_EXPIRED' }); + }, + allocate: async (request) => { + requests.push(request); + return grantedLease(request, { leaseId: 'lease-2' }); + }, + }), + }); + + assert.equal(requests.length, 1); + assert.equal(requests[0]?.deviceKey, 'ios:mobile:SIM-REMOTE'); + assert.equal(requests[0]?.osVersion, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('a spec recorded for an earlier lease neither serves nor describes the lease held now', async () => { // lease-1 was allocated by spec and has ended; lease-2 was then allocated by --udid without one. const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-stale-', { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 3839d6291f..f9583c71e1 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -928,7 +928,7 @@ async function allocateOrReuseLease( serial: flags.serial, iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, androidDeviceAllowlist: flags.androidDeviceAllowlist, - ...readLeaseDeviceSpec(flags), + ...(allocateBySpec ? readLeaseDeviceSpec(flags) : {}), providerApp: initialApp ?? flags.providerApp, providerOsVersion: flags.providerOsVersion, providerDeviceType: flags.providerDeviceType, diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 3ace48df44..273bf47874 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -132,9 +132,13 @@ export async function bindLeaseNamedDevice(options: { throw await releaseUnhonouredSpecLease(client, options.state, lease, specKeys); } if (leased) { + const identityConflict = leasedIdentityConflict(options.state, flags, leased); + if (identityConflict && acquired) { + await releaseLease(client, options.state, lease).catch(() => undefined); + } + if (identityConflict) throw identityConflict; if (!acquired) { assertLeaseSpecMatchesRequest(options.state, lease, spec, leased); - assertLeasedIdentityMatchesRequest(options.state, flags, leased); await assertLeasedDeviceMatchesRequest(client, options.state, flags, leased); } applyDeviceScopeSelector(flags, leased); @@ -230,15 +234,18 @@ function assertLeaseSpecMatchesRequest( ); } -/** A renewed lease serves a request naming `--udid` or `--serial` only when it leases that device. */ -function assertLeasedIdentityMatchesRequest( +/** + * A lease serves a request naming `--udid` or `--serial` only when it leases that device; a new + * lease for another device is released by the caller. + */ +function leasedIdentityConflict( state: RemoteConnectionState, flags: CliFlags, leased: ConnectionDeviceScope, -): void { +): AppError | undefined { const requested = flags.udid ?? flags.serial; - if (requested === undefined || requested === leased.id) return; - throw new AppError( + if (requested === undefined || requested === leased.id) return undefined; + return new AppError( 'INVALID_ARGS', 'Active remote connection is already bound to a different device. Re-run connect --force to replace it.', { diff --git a/src/commands/schema/option-schema.ts b/src/commands/schema/option-schema.ts index d53b41377b..35092c2b9f 100644 --- a/src/commands/schema/option-schema.ts +++ b/src/commands/schema/option-schema.ts @@ -33,6 +33,9 @@ const ENV_EXCLUDED_FLAG_KEYS = new Set([ 'appsFilter', 'iosSimulatorDeviceSet', 'textStdin', + // The proxy lease device spec comes from a flag or `connect proxy`, never ambient environment. + 'osVersion', + 'imageTag', ]); const optionSpecs = buildOptionSpecs(); From 1cebcf44794ca579f7b48fcfaa35401e5150814e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 11:53:25 +0200 Subject: [PATCH 5/7] feat(proxy): restore --physical on the by-spec lease allocate and physical on lease.device --- docs/adr/0007-remote-device-leases.md | 6 ++-- .../src/flag-definitions-target.ts | 12 +++++++ .../src/__tests__/lease-scope.test.ts | 31 +++++++++++++------ packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-lease.ts | 3 ++ packages/contracts/src/lease-scope.ts | 15 ++++++--- .../contracts/src/remote-config-fields.ts | 1 + packages/contracts/src/request-envelope.ts | 1 + packages/remote/src/remote-config-schema.ts | 1 + scripts/integration-progress-model.ts | 2 +- ...nt-device-client-lease-device-spec.test.ts | 4 +-- .../agent-device-client-leases.test.ts | 5 ++- src/cli/commands/__tests__/generic.test.ts | 6 ++-- .../__tests__/proxy-lease-device-spec.test.ts | 18 ++++++----- src/cli/commands/generic.ts | 9 ++++-- src/cli/commands/proxy-lease-device.ts | 3 +- src/cli/connection/proxy-profile.ts | 1 + src/commands/command-flags.ts | 1 + src/commands/management/lease-device-spec.ts | 5 +-- src/commands/schema/cli-help.ts | 4 +-- src/daemon-client/daemon-client-rpc.test.ts | 2 ++ .../command-tools-operator-inputs.test.ts | 2 +- website/docs/docs/remote-proxy.md | 8 +++-- 23 files changed, 98 insertions(+), 43 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index e6cd29f80d..ae78594a18 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -297,14 +297,14 @@ 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` and `imageTag` on `agent_device.lease.allocate`. +A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, 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 either key, before admission and before a lease is recorded. +Thus, a daemon refuses every request that names any of the three keys, 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`. -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?`). +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. A backend older than the spec returns a lease without `lease.device`. The client then releases that lease and fails with the same reason. diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 82d9c029a0..d97e898022 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: 'physical', + names: ['--physical'], + type: 'boolean', + usageLabel: '--physical', + usageDescription: + 'Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend', + inputDescription: + 'Ask a proxy backend to lease a physical device instead of a simulator or emulator. 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: 'stale', names: ['--stale'], diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 24fc55d8bc..61f1cfd15c 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -253,30 +253,41 @@ test('readLeaseAllocateProviderFlags carries every device-spec key a request nam platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', + physical: true, }), - { platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore' }, + { platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', physical: true }, ); // A malformed key still reaches the daemon, which refuses it rather than never seeing it. - assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, imageTag: false }), { + assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, physical: false }), { osVersion: 26, - imageTag: false, + physical: false, }); }); -test('readLeaseDeviceSpec names only a non-empty version or tag', () => { - const padded = { osVersion: ' >=26 ', imageTag: '' }; +test('readLeaseDeviceSpec names only a non-empty version or tag and physical: true', () => { + const padded = { osVersion: ' >=26 ', imageTag: '', physical: false }; assert.deepEqual(readLeaseDeviceSpec(padded), { osVersion: '>=26' }); - assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis' }), { imageTag: 'google_apis' }); + assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis', physical: true }), { + imageTag: 'google_apis', + physical: true, + }); assert.deepEqual(readLeaseDeviceSpec({}), {}); + // Only the boolean true asks for a physical device; a string or number names no spec. + assert.deepEqual(readLeaseDeviceSpec({ physical: 'yes' }), {}); + assert.deepEqual(readLeaseDeviceSpec({ physical: 1 }), {}); }); test('readLeaseDevice reads the granted facts and rejects an echo missing one', () => { - const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' }; + const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; assert.deepEqual(readLeaseDevice(pixel), pixel); - const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0' }; + const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0', physical: true }; assert.deepEqual(readLeaseDevice(iphone), iphone); - assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro' }), undefined); - assert.equal(readLeaseDevice({ osVersion: '26.0' }), undefined); + assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro', osVersion: '26.0' }), undefined); + assert.equal(readLeaseDevice({ osVersion: '26.0', physical: true }), undefined); + assert.equal( + readLeaseDevice({ model: 'iPhone 17 Pro', osVersion: '26.0', physical: 'yes' }), + undefined, + ); assert.equal(readLeaseDevice(undefined), undefined); }); diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 4b5c35d62e..dcf3103b5a 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 (`LEASE_DEVICE_SPEC_KEYS`). osVersion?: string; imageTag?: string; + physical?: boolean; stale?: boolean; iosSimulatorDeviceSet?: string; iosXctestrunFile?: string; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index f3aa6f9f62..eb66300acd 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -36,6 +36,8 @@ export type LeaseDeviceSpec = { osVersion?: string; /** Android system image tag, for example `google_apis_playstore`. */ imageTag?: string; + /** `true` asks for a physical device instead of a simulator or emulator. */ + physical?: boolean; }; /** The device facts the backend granted for a {@link LeaseDeviceSpec}. */ @@ -44,6 +46,7 @@ export type LeaseDevice = { /** The exact OS version granted. */ osVersion: string; imageTag?: string; + physical: boolean; }; /** diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 8be68de559..da7881af13 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -273,6 +273,7 @@ type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[n export const LEASE_DEVICE_SPEC_KEYS = [ 'osVersion', 'imageTag', + 'physical', ] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[]; type LeaseDeviceSpecKey = (typeof LEASE_DEVICE_SPEC_KEYS)[number]; @@ -318,14 +319,19 @@ export function namedLeaseDeviceSpecKeys( return LEASE_DEVICE_SPEC_KEYS.filter((key) => source?.[key] !== undefined); } -/** The device spec a CLI flags bag or profile asks for. An empty string names no version or tag. */ +/** + * The device spec a CLI flags bag or profile asks for. An empty string names no version or tag, + * and only `physical: true` asks for a physical device. + */ export function readLeaseDeviceSpec(source: { osVersion?: unknown; imageTag?: unknown; + physical?: unknown; }): LeaseDeviceSpec { return stripUndefined({ osVersion: nonEmptyString(source.osVersion), imageTag: nonEmptyString(source.imageTag), + physical: source.physical === true ? true : undefined, }); } @@ -335,26 +341,27 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined { const record = value as Record; const model = nonEmptyString(record.model); const osVersion = nonEmptyString(record.osVersion); - if (!model || !osVersion) return undefined; + if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined; const imageTag = nonEmptyString(record.imageTag); return { model, osVersion, ...(imageTag ? { imageTag } : {}), + physical: record.physical, }; } /** 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 or image tag.', + message = 'This daemon cannot pick a device by OS version, image tag, or physical/virtual class.', details: Record = {}, ): AppError { return new AppError('UNSUPPORTED_OPERATION', message, { ...details, reason: DEVICE_SPEC_UNSUPPORTED_REASON, keys: [...keys], - hint: 'Drop --os and --image-tag, or connect through a proxy whose backend picks devices by spec.', + hint: 'Drop --os, --image-tag and --physical, or connect through a proxy whose backend picks devices by spec.', }); } diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 67b48136cb..182dcc5b80 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -142,6 +142,7 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & serial?: string; osVersion?: string; imageTag?: string; + physical?: boolean; iosSimulatorDeviceSet?: string; androidDeviceAllowlist?: string; session?: string; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index bffe239a13..98b4661977 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -68,6 +68,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & surface?: SessionSurface; osVersion?: string; imageTag?: string; + physical?: boolean; activity?: string; launchConsole?: string; launchArgs?: string[]; diff --git a/packages/remote/src/remote-config-schema.ts b/packages/remote/src/remote-config-schema.ts index 8a97ea80ad..234bbb8509 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: 'physical', type: 'boolean', env: false }, { key: 'iosSimulatorDeviceSet', type: 'string', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index ffacf58619..1ecbfd6824 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'], + keys: ['osVersion', 'imageTag', 'physical'], }, { name: 'cloud artifact provider lookup', diff --git a/src/__tests__/agent-device-client-lease-device-spec.test.ts b/src/__tests__/agent-device-client-lease-device-spec.test.ts index 637b150e30..f8673a08cf 100644 --- a/src/__tests__/agent-device-client-lease-device-spec.test.ts +++ b/src/__tests__/agent-device-client-lease-device-spec.test.ts @@ -52,9 +52,9 @@ test('a daemon refuses a lease allocate naming a device spec and records no leas leaseBackend: 'ios-instance', platform: 'ios', osVersion: '>=26', - imageTag: 'iphone', + physical: true, }), - isDeviceSpecRefusal(['osVersion', 'imageTag']), + isDeviceSpecRefusal(['osVersion', 'physical']), ); await assertNoLeaseRecorded(client); // The same allocate without the spec is recorded, so the heartbeat above proves the refusal. diff --git a/src/__tests__/agent-device-client-leases.test.ts b/src/__tests__/agent-device-client-leases.test.ts index 30e7dd21d6..b295b98f0e 100644 --- a/src/__tests__/agent-device-client-leases.test.ts +++ b/src/__tests__/agent-device-client-leases.test.ts @@ -44,7 +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' }, + device: { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }, }, }, })); @@ -58,13 +58,16 @@ test('lease allocation sends the device spec and returns the device the backend platform: 'android', osVersion: '34', imageTag: 'google_apis', + 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?.physical, true); assert.deepEqual(lease.device, { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', + physical: false, }); }); diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index 6032eb0728..ccd6ea878a 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -123,7 +123,7 @@ test('open --json prints the device a proxy backend granted under lease.device', }, }, ); - const device = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' }; + const device = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis', physical: false }; const json = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ @@ -149,7 +149,7 @@ test('open --json prints the device a proxy backend granted under lease.device', leaseDevice: device, }), ); - assert.match(human, /Leased device: Pixel 9, 34, google_apis/); + assert.match(human, /Leased device: Pixel 9, 34, google_apis, virtual/); }); test('open --json at a non-default response level still prints lease.device', async () => { @@ -157,7 +157,7 @@ test('open --json at a non-default response level still prints lease.device', as { session: 'qa', responseLevel: 'digest' }, { transport: async (): Promise => ({ ok: true, data: { session: 'qa' } }) }, ); - const device = { model: 'iPhone 17 Pro', osVersion: '26.1' }; + const device = { model: 'iPhone 17 Pro', osVersion: '26.1', physical: true }; const json = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ 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 b0180c12f4..c0e85772e2 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -31,6 +31,7 @@ bindTestCommandHost(); const GRANTED_DEVICE: LeaseDevice = { model: 'iPhone 17 Pro', osVersion: '26.1', + physical: false, }; function proxyWorkspace( @@ -118,7 +119,7 @@ test('proxy open by device spec sends the spec on allocate without listing devic const materialized = await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'open', - flags: { ...flags, device: 'iPhone 17 Pro', osVersion: '>=26', imageTag: 'iphone' }, + flags: { ...flags, device: 'iPhone 17 Pro', osVersion: '>=26', physical: true }, client: createTestClient({ listDevices: unexpectedCommandCall, allocate: async (request) => { @@ -134,17 +135,18 @@ test('proxy open by device spec sends the spec on allocate without listing devic assert.equal(requests[0]?.platform, 'ios'); assert.equal(requests[0]?.device, 'iPhone 17 Pro'); assert.equal(requests[0]?.osVersion, '>=26'); - assert.equal(requests[0]?.imageTag, 'iphone'); + 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.imageTag, undefined); + assert.equal(materialized.flags.physical, undefined); const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); assert.equal(state?.leaseId, 'lease-1'); assert.deepEqual(state?.leaseDeviceSpec, { leaseId: 'lease-1', - spec: { osVersion: '>=26', imageTag: 'iphone' }, + spec: { osVersion: '>=26', physical: true }, device: GRANTED_DEVICE, }); fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -192,7 +194,7 @@ test('a failed release of an unconfirmed spec lease still fails the command with materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'open', - flags: { ...flags, imageTag: 'google_apis' }, + flags: { ...flags, physical: true }, client: createTestClient({ listDevices: unexpectedCommandCall, allocate: async (request) => grantedLease(request), @@ -269,13 +271,13 @@ test('a spec named on a live lease allocated without one is refused with CONNECT materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), command: 'install', - flags: { ...flags, imageTag: 'google_apis' }, + flags: { ...flags, physical: true }, client: renewingClient([]), }), (error: AppError) => error.code === 'INVALID_ARGS' && error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && - JSON.stringify(error.details?.keys) === '["imageTag"]', + JSON.stringify(error.details?.keys) === '["physical"]', ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -408,6 +410,7 @@ test('connect proxy stores the device spec as profile defaults like platform and platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore', + physical: true, }, client: createTestClient(), }); @@ -422,5 +425,6 @@ 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.physical, true); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 44fcb9d2cc..1cb663d503 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -99,9 +99,12 @@ async function writeGenericCliOutput( /** Adds the device a proxy backend granted for the lease's device spec to the `open` output. */ function withLeaseDevice(output: CliOutput, device: LeaseDevice): CliOutput { const lease = { lease: { device } }; - const facts = [device.model, device.osVersion, device.imageTag].filter((fact): fact is string => - Boolean(fact), - ); + const facts = [ + device.model, + device.osVersion, + device.imageTag, + device.physical ? 'physical' : 'virtual', + ].filter((fact): fact is string => Boolean(fact)); const leasedLine = `Leased device: ${facts.join(', ')}`; return { ...output, diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 273bf47874..8e8c7c1003 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -64,7 +64,7 @@ export async function resolveProxyLeaseState(options: { if (!options.leaseBackend) { throw new AppError( 'INVALID_ARGS', - `${options.command} with --os or --image-tag requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, + `${options.command} with --os, --image-tag or --physical requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, ); } return { state: options.state, allocateBySpec: true }; @@ -144,6 +144,7 @@ export async function bindLeaseNamedDevice(options: { applyDeviceScopeSelector(flags, leased); delete flags.osVersion; delete flags.imageTag; + delete flags.physical; return { state: { ...options.state, diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index e6b9c9654c..d254069eaa 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -38,6 +38,7 @@ export function resolveProxyConnectProfile(options: { serial: options.flags.serial, osVersion: options.flags.osVersion, imageTag: options.flags.imageTag, + physical: options.flags.physical, iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, androidDeviceAllowlist: options.flags.androidDeviceAllowlist, session: options.flags.session, diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 9612e5af74..c139a6a385 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -59,6 +59,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { androidDeviceAllowlist: options.androidDeviceAllowlist, osVersion: options.osVersion, imageTag: options.imageTag, + physical: options.physical, surface: options.surface, activity: options.activity, launchConsole: options.launchConsole, diff --git a/src/commands/management/lease-device-spec.ts b/src/commands/management/lease-device-spec.ts index 0ed27ef8ca..4cbafd3950 100644 --- a/src/commands/management/lease-device-spec.ts +++ b/src/commands/management/lease-device-spec.ts @@ -10,11 +10,12 @@ import { optionField } from '../command-input.ts'; export const leaseDeviceSpecFields = { osVersion: optionField('osVersion'), imageTag: optionField('imageTag'), + physical: optionField('physical'), }; -/** The spec keys the flags name; an empty version or tag names none. */ +/** The spec keys the flags name; an empty version or tag and `physical: false` name none. */ export function leaseDeviceSpecInputFromFlags( flags: CliFlags, -): Pick { +): Pick { return readLeaseDeviceSpec(flags); } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 179b2999d3..8ed6673786 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -565,10 +565,10 @@ Direct proxy flow for a remote Mac/simulator: agent-device disconnect 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 and --image-tag join them; the backend validates their values (for example, Android --os is an API level). connect proxy stores them 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 , and --physical join them; the backend validates their values (for example, Android --os is an API level). connect proxy stores them 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); open --json prints it. A daemon, local or behind agent-device proxy, refuses both keys with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. A lease returned without lease.device is released, and the command fails with the same reason. 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. A daemon, local or behind agent-device proxy, refuses the three keys with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. A lease returned without lease.device is released, and the command fails with the same reason. 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/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index 57c8f4cdf0..a8c49fdce7 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -201,6 +201,7 @@ test('lease allocation carries the device spec a proxy backend picks by', () => platform: 'android', osVersion: '>=34', imageTag: 'google_apis_playstore', + physical: true, }, meta: { requestId: 'lease-req', tenantId: 'proxy', runId: 'proxy-run' }, }, @@ -215,5 +216,6 @@ test('lease allocation carries the device spec a proxy backend picks by', () => platform: 'android', osVersion: '>=34', imageTag: 'google_apis_playstore', + physical: true, }); }); diff --git a/src/mcp/__tests__/command-tools-operator-inputs.test.ts b/src/mcp/__tests__/command-tools-operator-inputs.test.ts index 26d7153217..c43c0faeb7 100644 --- a/src/mcp/__tests__/command-tools-operator-inputs.test.ts +++ b/src/mcp/__tests__/command-tools-operator-inputs.test.ts @@ -265,7 +265,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']) { + for (const key of ['osVersion', 'imageTag', 'physical']) { 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 15fb913bd2..24b3fb3d5b 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -59,21 +59,23 @@ 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 and Android image tag. Pass `--os ` or `--image-tag ` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. `connect proxy` stores them as defaults like `--platform` and `--device`. agent-device does not check the values; the service does (for example, Android `--os` is an API level). `--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, 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. `connect proxy` stores them as defaults like `--platform` and `--device`. agent-device does not check the values; the service does (for example, Android `--os` is an API level). `--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 ``` | Flag | Key on `agent_device.lease.allocate` | Type | | --- | --- | --- | | `--os ` | `osVersion` | string | | `--image-tag ` | `imageTag` | string | +| `--physical` | `physical` | `true` | -A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), and `imageTag` when one applies. `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`. -The daemon itself cannot pick a device by these keys, so it refuses them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. While a lease is active, a later command that names a different value for either key 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 them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. 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. ## Human takeover From fbffe95548c6e66172fb044cded4caf2b1779feb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 12:57:05 +0200 Subject: [PATCH 6/7] docs(proxy): say what --physical leases --- website/docs/docs/remote-proxy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 24b3fb3d5b..d3365ecab6 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -73,6 +73,8 @@ agent-device open MyApp --platform ios --physical --os 18.6.2 | `--image-tag ` | `imageTag` | 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 them instead of ignoring them. A local daemon, or one behind a plain `agent-device proxy`, fails the 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`, it did not understand the spec: the CLI releases that lease and fails with the same reason. 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. From df790b73aa3cc145b9d7db7f04f865badc2857c2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 13:06:58 +0200 Subject: [PATCH 7/7] fix(proxy): keep --physical out of the environment like --os and --image-tag --- src/commands/schema/option-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/schema/option-schema.ts b/src/commands/schema/option-schema.ts index 35092c2b9f..7adc7b58f2 100644 --- a/src/commands/schema/option-schema.ts +++ b/src/commands/schema/option-schema.ts @@ -36,6 +36,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', + 'physical', ]); const optionSpecs = buildOptionSpecs();