diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 2d5b99a282..ae78594a18 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -294,3 +294,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..d97e898022 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). 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: '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. Only the CLI allocating a lease through connect proxy sends it to that backend; a daemon refuses every other request that names it, with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, + { + key: 'physical', + names: ['--physical'], + 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 f3f4ae1804..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 { @@ -33,6 +34,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 = 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 d83bc96da3..61f1cfd15c 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,50 @@ 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({}), {}); + // 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', 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({ model: 'iPhone 17 Pro', osVersion: '26.0', physical: 'yes' }), + 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 9325a37ae9..bd36328335 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..da7881af13 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. */ +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 72fec0c6ad..b239eb7e16 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, resolveDaemonRequestTimeoutMs, @@ -260,6 +264,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; @@ -563,6 +571,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..9e5e460d30 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,11 @@ export type RemoteConnectionState = { clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; + /** + * 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; @@ -498,10 +504,15 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'deviceKey', 'clientId', ]) && - isOptionalRemoteConnectionDaemonState(record.daemon) + isOptionalRemoteConnectionDaemonState(record.daemon) && + isOptionalRecord(record.leaseDeviceSpec) ); } +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-lease-device-spec.test.ts b/src/__tests__/agent-device-client-lease-device-spec.test.ts new file mode 100644 index 0000000000..f8673a08cf --- /dev/null +++ b/src/__tests__/agent-device-client-lease-device-spec.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__/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/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..ccd6ea878a 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -112,3 +112,62 @@ 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/); +}); + +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', physical: true }; + + 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 new file mode 100644 index 0000000000..c0e85772e2 --- /dev/null +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -0,0 +1,430 @@ +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 '../../../__tests__/remote-connection.fixtures.ts'; + +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), +})); + +import { materializeRemoteConnectionForCommand } from '../connection-runtime.ts'; +import { + readActiveConnectionState, + readRemoteConnectionState, +} from '@agent-device/remote/remote-connection-state'; +import { connectCommand } from '../../../__tests__/test-utils/host-bound-commands.ts'; +import { withCapturedStdoutAsync } from '../../../__tests__/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; + leaseDeviceSpec?: { leaseId: string; spec: LeaseDeviceSpec; device?: LeaseDevice }; + } = {}, +) { + 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.leaseDeviceSpec ? { leaseDeviceSpec: seed.leaseDeviceSpec } : {}), + }, + }); + 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?.leaseDeviceSpec, { + leaseId: 'lease-1', + spec: { osVersion: '>=26', physical: true }, + device: 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', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { osVersion: '>=26' }, device: GRANTED_DEVICE }, + }); + 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); + assert.deepEqual(materialized.leaseDevice, GRANTED_DEVICE); + 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', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { 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?.leaseDeviceSpec?.spec, { 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('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 }); +}); + +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-', { + 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-'); + + 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/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 28a8a36e32..f9583c71e1 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,10 +39,11 @@ 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, + grantedLeaseDevice, resolveProxyLeaseState, type ResolvedLeaseState, } from './proxy-lease-device.ts'; @@ -73,6 +74,7 @@ export async function materializeRemoteConnectionForCommand(options: { flags: CliFlags; runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; + 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: grantedLeaseDevice(nextState), }; } @@ -925,18 +928,14 @@ async function allocateOrReuseLease( serial: flags.serial, iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, androidDeviceAllowlist: flags.androidDeviceAllowlist, + ...(allocateBySpec ? 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..1cb663d503 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,12 +33,20 @@ 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 // 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) { @@ -83,3 +95,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..8e8c7c1003 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 { 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'; @@ -35,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; @@ -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,41 @@ 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); + 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); + 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 + ? { leaseDeviceSpec: { leaseId: lease.leaseId, spec, device: 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 +167,109 @@ 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, + lease: Lease, + requested: LeaseDeviceSpec, + leased: ConnectionDeviceScope, +): void { + const bound = recordedLeaseDeviceSpec(state, lease.leaseId)?.spec ?? {}; + 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', + }, + ); +} + +/** + * 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, +): AppError | undefined { + const requested = flags.udid ?? flags.serial; + 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.', + { + session: state.session, + deviceKey: buildConnectionDeviceKey(leased), + requestedDevice: requested, + reason: 'CONNECTION_DEVICE_CONFLICT', + }, + ); +} + +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', 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 57950cd86d..8ed6673786 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -564,6 +564,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/commands/schema/option-schema.ts b/src/commands/schema/option-schema.ts index d53b41377b..7adc7b58f2 100644 --- a/src/commands/schema/option-schema.ts +++ b/src/commands/schema/option-schema.ts @@ -33,6 +33,10 @@ 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', + 'physical', ]); const optionSpecs = buildOptionSpecs(); 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..c43c0faeb7 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 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']) { + 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 7d28626c98..d3365ecab6 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -57,6 +57,28 @@ 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` | + +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. + ## Human takeover With a remote device already leased by `open`, pause mutations through the same connection: