From b7331fa392b4b124b6f2d00f4bf27baa8aa0f87e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 5 Nov 2026 14:30:47 +0100 Subject: [PATCH] feat(lease): name a backend baseline in a proxy lease device spec --- docs/adr/0007-remote-device-leases.md | 11 ++- .../client/src/__tests__/index-leases.test.ts | 35 +++++++ .../src/flag-definitions-target.ts | 12 +++ packages/command-registry/src/flag-groups.ts | 1 + .../command-surface/src/cli-grammar/common.ts | 2 +- packages/command-surface/src/command-flags.ts | 1 + packages/command-surface/src/command-input.ts | 10 +- .../src/schema/option-schema.ts | 1 + .../src/__tests__/lease-scope.test.ts | 45 ++++++++- packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-lease.ts | 4 + packages/contracts/src/lease-scope.ts | 31 +++++- .../contracts/src/remote-config-fields.ts | 1 + packages/contracts/src/request-envelope.ts | 1 + .../request-router-device-spec-gate.test.ts | 13 +++ packages/remote/src/remote-config-schema.ts | 1 + scripts/integration-progress-model.ts | 2 +- .../__tests__/proxy-lease-device-spec.test.ts | 96 ++++++++++++++++++- src/cli/commands/proxy-lease-device.ts | 9 +- src/commands/schema/cli-help.ts | 4 +- website/docs/docs/remote-proxy.md | 8 +- 21 files changed, 261 insertions(+), 28 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index ccae133887..c5848ee49b 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -329,25 +329,28 @@ 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`, `deviceMode`, and `physical` on `agent_device.lease.allocate`. +A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, `deviceMode`, `deviceBaseline`, and `physical` on `agent_device.lease.allocate`. Only a backend in front of the daemon, such as simlock, can pick a device by these keys. A daemon cannot pick by them. If a daemon ignored them, a request would run on a device that it did not ask for. -Thus, a daemon refuses every request that names a non-blank `osVersion`, `imageTag` or `deviceMode`, or `physical: true`, before admission and before a lease is recorded. +Thus, a daemon refuses every request that names a non-blank `osVersion`, `imageTag`, `deviceMode` or `deviceBaseline`, or `physical: true`, before admission and before a lease is recorded. The refusal is `UNSUPPORTED_OPERATION` with `details.reason: 'device-spec-unsupported'` and `details.keys`. This applies to a local daemon and to a daemon behind `agent-device proxy`. -A blank `osVersion`, `imageTag` or `deviceMode` and `physical: false` name nothing, so a daemon accepts them. +A blank `osVersion`, `imageTag`, `deviceMode` or `deviceBaseline` and `physical: false` name nothing, so a daemon accepts them. `deviceMode` (`slim` or `full`) chooses how much of an iOS simulator the backend keeps running; the backend validates the value. +`deviceBaseline` names a saved, prepared device state that the backend restores on the leased device; the backend validates the name. A named value is used as given, without trimming. A value of another type is refused with `INVALID_ARGS`, `details.reason: 'device-spec-invalid'`, and `details.keys`. `readLeaseDeviceSpec` is the one decision on these rules, for CLI flags, the SDK, MCP, and the daemon. A remote config file first applies the value rules of every config key: a string must be non-empty, and a boolean also accepts a boolean string such as `"true"`. -The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `deviceMode?`, `physical`). +The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `deviceMode?`, `baseline?`, `physical`). `deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood. A backend can report the granted mode as `lease.device.deviceMode`. The CLI proxy connection treats a new lease that reports `slim` for a `full` request like a lease without `lease.device`. Full is a guarantee and slim is best effort, so `full` granted for a `slim` request is served. A lease that reports no mode is accepted, so the proof does not confirm `deviceMode`: a backend that ignores the key and reports no mode grants its default without an error. +A backend reports a restored baseline as `lease.device.baseline` (`name`, and `version`, a positive integer). +The CLI proxy connection treats a new lease allocated with `deviceBaseline` whose `baseline.name` is not the requested name like a lease without `lease.device`. A backend older than the spec returns a lease without `lease.device`. The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`. `details.released` reports whether the release succeeded. diff --git a/packages/client/src/__tests__/index-leases.test.ts b/packages/client/src/__tests__/index-leases.test.ts index cb51273935..3c0f4075b7 100644 --- a/packages/client/src/__tests__/index-leases.test.ts +++ b/packages/client/src/__tests__/index-leases.test.ts @@ -101,3 +101,38 @@ test('lease allocation sends the iOS simulator mode and returns the mode the bac assert.equal(setup.calls[0]?.flags?.deviceMode, 'full'); assert.deepEqual(lease.device, device); }); + +test('lease allocation sends the baseline and returns the baseline the backend granted', async () => { + const device = { + model: 'iPhone 17 Pro', + osVersion: '26.2', + physical: false, + baseline: { name: 'logged-in', version: 2 }, + }; + const setup = createClientTransport(async (req) => ({ + ok: true, + data: { + lease: { + leaseId: 'lease-baseline', + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + backend: req.meta?.leaseBackend, + deviceKey: 'ios:mobile:SIM-1', + device, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const lease = await client.leases.allocate({ + tenant: 'proxy', + runId: 'proxy-run', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + platform: 'ios', + deviceBaseline: 'logged-in', + }); + + assert.equal(setup.calls[0]?.flags?.deviceBaseline, 'logged-in'); + assert.deepEqual(lease.device, device); +}); diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 744cb6fb12..107e9dbd85 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -88,6 +88,18 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: false, recorded: false, }, + { + key: 'deviceBaseline', + names: ['--device-baseline'], + type: 'string', + usageLabel: '--device-baseline ', + usageDescription: + 'Proxy lease: saved device state (baseline) the backend restores on the leased device; refused without such a backend', + inputDescription: + 'Name of a saved, prepared device state (baseline) that a proxy backend restores on the device it leases, sent as given; the backend validates it. A blank string names nothing; a non-string fails with INVALID_ARGS, reason device-spec-invalid. A new lease that does not report this baseline in lease.device.baseline is released and fails with reason device-spec-unsupported. Only the CLI allocating a lease through connect proxy sends it to that backend; a daemon refuses every other request that names it, with reason device-spec-unsupported.', + projectConfig: false, + recorded: false, + }, { key: 'physical', names: ['--physical'], diff --git a/packages/command-registry/src/flag-groups.ts b/packages/command-registry/src/flag-groups.ts index dfa837fd6c..b07bb93950 100644 --- a/packages/command-registry/src/flag-groups.ts +++ b/packages/command-registry/src/flag-groups.ts @@ -39,6 +39,7 @@ export const LEASE_DEVICE_SPEC_FLAGS = flagKeys( 'osVersion', 'imageTag', 'deviceMode', + 'deviceBaseline', 'physical', ) satisfies typeof LEASE_DEVICE_SPEC_KEYS; export const REPEATED_TOUCH_FLAGS = flagKeys( diff --git a/packages/command-surface/src/cli-grammar/common.ts b/packages/command-surface/src/cli-grammar/common.ts index 707e205356..e16abff988 100644 --- a/packages/command-surface/src/cli-grammar/common.ts +++ b/packages/command-surface/src/cli-grammar/common.ts @@ -147,7 +147,7 @@ export function repeatedInputFromFlags(flags: CliFlags): Record /** The spec keys the flags name, as `requireLeaseDeviceSpec` decides them; an invalid key throws. */ export function leaseDeviceSpecInputFromFlags( flags: CliFlags, -): Pick { +): Pick { return requireLeaseDeviceSpec(flags).spec; } diff --git a/packages/command-surface/src/command-flags.ts b/packages/command-surface/src/command-flags.ts index f58a664506..fc2be6a74f 100644 --- a/packages/command-surface/src/command-flags.ts +++ b/packages/command-surface/src/command-flags.ts @@ -62,6 +62,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { osVersion: options.osVersion, imageTag: options.imageTag, deviceMode: options.deviceMode, + deviceBaseline: options.deviceBaseline, physical: options.physical, surface: options.surface, activity: options.activity, diff --git a/packages/command-surface/src/command-input.ts b/packages/command-surface/src/command-input.ts index b8296f2508..167145b874 100644 --- a/packages/command-surface/src/command-input.ts +++ b/packages/command-surface/src/command-input.ts @@ -12,7 +12,10 @@ import { import { getFlagDefinitionsForKey } from '@agent-device/command-registry/flag-registry'; import type { FlagDefinition, FlagKey } from '@agent-device/command-registry/flag-types'; import { AppError } from '@agent-device/kernel/errors'; -import { requireLeaseDeviceSpec } from '@agent-device/contracts/lease-scope'; +import { + type LEASE_DEVICE_SPEC_KEYS, + requireLeaseDeviceSpec, +} from '@agent-device/contracts/lease-scope'; import type { RepeatedInput } from '@agent-device/contracts/interaction'; import type { JsonSchema } from './command-contract.ts'; import { @@ -433,13 +436,12 @@ export function leaseDeviceSpecFields() { osVersion: leaseDeviceSpecField('osVersion'), imageTag: leaseDeviceSpecField('imageTag'), deviceMode: leaseDeviceSpecField('deviceMode'), + deviceBaseline: leaseDeviceSpecField('deviceBaseline'), physical: leaseDeviceSpecField('physical'), }; } -function leaseDeviceSpecField( - key: TKey, -) { +function leaseDeviceSpecField(key: TKey) { return customField( optionField(key).schema, (record) => requireLeaseDeviceSpec({ [key]: record[key] }).spec[key], diff --git a/packages/command-surface/src/schema/option-schema.ts b/packages/command-surface/src/schema/option-schema.ts index 3fb03a4d5b..c80540f98b 100644 --- a/packages/command-surface/src/schema/option-schema.ts +++ b/packages/command-surface/src/schema/option-schema.ts @@ -34,6 +34,7 @@ const ENV_EXCLUDED_FLAG_KEYS = new Set([ 'osVersion', 'imageTag', 'deviceMode', + 'deviceBaseline', 'physical', ]); diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 59b84c7cd7..c7ffdadc1a 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -275,11 +275,18 @@ test('readLeaseDeviceSpec decides each key is named, not named, or invalid', () osVersion: '>=26', imageTag: 'google_apis', deviceMode: 'full', + deviceBaseline: 'logged-in', physical: true, }), { - spec: { osVersion: '>=26', imageTag: 'google_apis', deviceMode: 'full', physical: true }, - keys: ['osVersion', 'imageTag', 'deviceMode', 'physical'], + spec: { + osVersion: '>=26', + imageTag: 'google_apis', + deviceMode: 'full', + deviceBaseline: 'logged-in', + physical: true, + }, + keys: ['osVersion', 'imageTag', 'deviceMode', 'deviceBaseline', 'physical'], invalidKeys: [], }, ); @@ -287,15 +294,27 @@ test('readLeaseDeviceSpec decides each key is named, not named, or invalid', () assert.deepEqual(readLeaseDeviceSpec(undefined), notNamed); assert.deepEqual(readLeaseDeviceSpec({}), notNamed); assert.deepEqual( - readLeaseDeviceSpec({ osVersion: '', imageTag: ' ', deviceMode: '', physical: false }), + readLeaseDeviceSpec({ + osVersion: '', + imageTag: ' ', + deviceMode: '', + deviceBaseline: ' ', + physical: false, + }), notNamed, ); assert.deepEqual( - readLeaseDeviceSpec({ osVersion: 26, imageTag: null, deviceMode: true, physical: 'yes' }), + readLeaseDeviceSpec({ + osVersion: 26, + imageTag: null, + deviceMode: true, + deviceBaseline: 1, + physical: 'yes', + }), { spec: {}, keys: [], - invalidKeys: ['osVersion', 'imageTag', 'deviceMode', 'physical'], + invalidKeys: ['osVersion', 'imageTag', 'deviceMode', 'deviceBaseline', 'physical'], }, ); assert.deepEqual(readLeaseDeviceSpec({ osVersion: '18', physical: 1 }), { @@ -338,6 +357,22 @@ test('readLeaseDevice reads the granted facts and rejects an echo missing one', assert.equal(readLeaseDevice(undefined), undefined); }); +test('readLeaseDevice reads the granted baseline and drops a malformed one', () => { + const device = { model: 'iPhone 17 Pro', osVersion: '26.0', physical: false }; + const withBaseline = { ...device, baseline: { name: 'logged-in', version: 3 } }; + assert.deepEqual(readLeaseDevice(withBaseline), withBaseline); + for (const baseline of [ + { name: 'logged-in', version: 0 }, + { name: 'logged-in', version: 1.5 }, + { name: 'logged-in', version: '3' }, + { name: ' ', version: 3 }, + { version: 3 }, + 'logged-in', + ]) { + assert.deepEqual(readLeaseDevice({ ...device, baseline }), device); + } +}); + 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 8ff0d4b9a7..04acb160c5 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -66,6 +66,7 @@ export type CliFlags = CloudProviderProfileFields & osVersion?: string; imageTag?: string; deviceMode?: string; + deviceBaseline?: string; physical?: boolean; stale?: boolean; iosSimulatorDeviceSet?: string; diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index 49170a007c..b88cca9594 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -45,6 +45,8 @@ export type LeaseDeviceSpec = { imageTag?: string; /** iOS simulator mode, `slim` or `full`; the backend validates it. */ deviceMode?: string; + /** Name of a saved, prepared device state the backend restores; the backend validates it. */ + deviceBaseline?: string; /** `true` asks for a physical device instead of a simulator or emulator. */ physical?: boolean; }; @@ -57,6 +59,8 @@ export type LeaseDevice = { imageTag?: string; /** The iOS simulator mode granted, when the backend reports it. */ deviceMode?: string; + /** The baseline the backend restored, when the lease was allocated with one. */ + baseline?: { name: string; version: number }; physical: boolean; }; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 1290aa969c..81a59db6e1 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -277,6 +277,7 @@ export const LEASE_DEVICE_SPEC_KEYS = [ 'osVersion', 'imageTag', 'deviceMode', + 'deviceBaseline', 'physical', ] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[]; @@ -331,13 +332,20 @@ export type LeaseDeviceSpecReading = { /** * The one decision on what a device spec names, for the CLI, the SDK, MCP and the daemon alike. - * Each key is named, not named, or invalid: a non-blank `osVersion`, `imageTag` or `deviceMode` and + * Each key is named, not named, or invalid: a non-blank `osVersion`, `imageTag`, `deviceMode` or + * `deviceBaseline` and * `physical: true` are named, with the value as given; an absent key, a blank string and * `physical: false` name nothing; any other type is invalid. */ export function readLeaseDeviceSpec( source: - | { osVersion?: unknown; imageTag?: unknown; deviceMode?: unknown; physical?: unknown } + | { + osVersion?: unknown; + imageTag?: unknown; + deviceMode?: unknown; + deviceBaseline?: unknown; + physical?: unknown; + } | undefined, ): LeaseDeviceSpecReading { const spec: Record = {}; @@ -371,26 +379,39 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined { if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined; const imageTag = nonEmptyString(record.imageTag); const deviceMode = nonEmptyString(record.deviceMode); + const baseline = readLeaseDeviceBaseline(record.baseline); return { model, osVersion, ...(imageTag ? { imageTag } : {}), ...(deviceMode ? { deviceMode } : {}), + ...(baseline ? { baseline } : {}), physical: record.physical, }; } +function readLeaseDeviceBaseline(value: unknown): LeaseDevice['baseline'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const name = nonEmptyString(record.name); + const { version } = record; + if (!name || typeof version !== 'number' || !Number.isInteger(version) || version < 1) { + return undefined; + } + return { name, version }; +} + /** 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, device mode, or physical/virtual class.', + message = 'This daemon cannot pick a device by OS version, image tag, device mode, baseline, 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, --device-mode and --physical, or connect through a proxy whose backend picks devices by spec.', + hint: 'Drop --os, --image-tag, --device-mode, --device-baseline and --physical, or connect through a proxy whose backend picks devices by spec.', }); } @@ -398,7 +419,7 @@ export function deviceSpecUnsupportedError( export function deviceSpecInvalidError(keys: readonly string[]): AppError { return new AppError( 'INVALID_ARGS', - 'osVersion, imageTag and deviceMode take a string, and physical takes a boolean.', + 'osVersion, imageTag, deviceMode and deviceBaseline take a string, and physical takes a boolean.', { reason: DEVICE_SPEC_INVALID_REASON, keys: [...keys] }, ); } diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 4b214d9839..5a35acc074 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -152,6 +152,7 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & osVersion?: string; imageTag?: string; deviceMode?: string; + deviceBaseline?: string; physical?: boolean; iosSimulatorDeviceSet?: string; androidDeviceAllowlist?: string; diff --git a/packages/contracts/src/request-envelope.ts b/packages/contracts/src/request-envelope.ts index a2ff4ffbbd..8882de1551 100644 --- a/packages/contracts/src/request-envelope.ts +++ b/packages/contracts/src/request-envelope.ts @@ -71,6 +71,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & osVersion?: string; imageTag?: string; deviceMode?: string; + deviceBaseline?: string; physical?: boolean; activity?: string; launchConsole?: string; diff --git a/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts b/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts index 28b4669b1f..657f593461 100644 --- a/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-device-spec-gate.test.ts @@ -89,6 +89,19 @@ test('a command naming a device mode is refused with device-spec-unsupported bef expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled(); }); +test('a command naming a device baseline is refused with device-spec-unsupported before it runs', async () => { + const response = await sendAppSwitcher({ deviceBaseline: 'logged-in' }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'device-spec-unsupported', keys: ['deviceBaseline'] }, + }, + }); + expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled(); +}); + test('a command giving a spec key the wrong type is refused with device-spec-invalid before it runs', async () => { // The named osVersion would be refused as unsupported; the invalid key is reported instead. const response = await sendAppSwitcher({ diff --git a/packages/remote/src/remote-config-schema.ts b/packages/remote/src/remote-config-schema.ts index 6f12cf86d5..517a0931d2 100644 --- a/packages/remote/src/remote-config-schema.ts +++ b/packages/remote/src/remote-config-schema.ts @@ -51,6 +51,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'osVersion', type: 'string', env: false }, { key: 'imageTag', type: 'string', env: false }, { key: 'deviceMode', type: 'string', env: false }, + { key: 'deviceBaseline', type: 'string', env: false }, { key: 'physical', type: 'boolean', env: false }, { key: 'iosSimulatorDeviceSet', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 2cb9b2f827..2dbbaa6cbe 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', 'deviceMode', 'physical'], + keys: ['osVersion', 'imageTag', 'deviceMode', 'deviceBaseline', 'physical'], }, { name: 'cloud artifact provider lookup', diff --git a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index 0b120eca98..eadb8cf9e7 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -68,9 +68,14 @@ test('proxy open by device spec sends the spec on allocate without listing devic osVersion: ' >=26 ', imageTag: 'google_apis', deviceMode: 'full', + deviceBaseline: 'logged-in', physical: true, }; - const grantedDevice: LeaseDevice = { ...GRANTED_DEVICE, deviceMode: 'full' }; + const grantedDevice: LeaseDevice = { + ...GRANTED_DEVICE, + deviceMode: 'full', + baseline: { name: 'logged-in', version: 2 }, + }; const materialized = await materializeRemoteConnectionForCommand({ host: createTestHostEnvironment(), @@ -142,6 +147,46 @@ test('a slim grant for a full request is released and refused', async () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); +for (const [label, baseline] of [ + ['another baseline', { name: 'factory', version: 1 }], + ['no baseline', undefined], +] as const) { + test(`a grant reporting ${label} for a baseline request is released and refused`, async () => { + const { tempRoot, stateDir, flags } = proxyWorkspace('agent-device-proxy-spec-wrong-baseline-'); + const releases: LeaseReleaseRequest[] = []; + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, deviceBaseline: 'logged-in' }, + client: createTestClient({ + listDevices: unexpectedCommandCall, + allocate: async (request) => + grantedLease(request, { + created: true, + deviceKey: REMOTE_DEVICE_KEY, + device: { ...GRANTED_DEVICE, ...(baseline ? { baseline } : {}) }, + }), + 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) === '["deviceBaseline"]', + ); + assert.deepEqual( + releases.map((request) => request.leaseId), + ['lease-1'], + ); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); +} + test('a full grant serves a slim request, because slim is best effort', async () => { const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-spec-slim-full-'); const device: LeaseDevice = { ...GRANTED_DEVICE, deviceMode: 'full' }; @@ -495,6 +540,51 @@ test('a spec named on a live lease allocated without one is refused with CONNECT fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('a later open naming the baseline a live lease was allocated by is served', async () => { + const device: LeaseDevice = { ...GRANTED_DEVICE, baseline: { name: 'logged-in', version: 2 } }; + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-same-baseline-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { deviceBaseline: 'logged-in' }, device }, + }); + const heartbeats: string[] = []; + + const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'install', + flags: { ...flags, deviceBaseline: 'logged-in' }, + client: renewingClient(heartbeats), + }); + + assert.deepEqual(heartbeats, ['lease-1']); + assert.equal(materialized.flags.udid, 'SIM-REMOTE'); + assert.equal(materialized.flags.deviceBaseline, undefined); + assert.deepEqual(materialized.leaseDevice, device); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('a later open naming another baseline on a live lease is refused with CONNECTION_DEVICE_CONFLICT', async () => { + const { tempRoot, flags } = proxyWorkspace('agent-device-proxy-device-spec-other-baseline-', { + leaseId: 'lease-1', + deviceKey: 'ios:mobile:SIM-REMOTE', + leaseDeviceSpec: { leaseId: 'lease-1', spec: { deviceBaseline: 'logged-in' } }, + }); + + await assert.rejects( + materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), + command: 'open', + flags: { ...flags, deviceBaseline: 'factory' }, + client: renewingClient([]), + }), + (error: AppError) => + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_DEVICE_CONFLICT' && + JSON.stringify(error.details?.keys) === '["deviceBaseline"]', + ); + 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', @@ -566,6 +656,7 @@ test('a lease re-allocated by its stored device for a non-allocating command doe osVersion: '>=26', imageTag: 'google_apis', deviceMode: 'full', + deviceBaseline: 'logged-in', physical: true, }, client: createTestClient({ @@ -631,6 +722,7 @@ test('connect proxy stores the device spec as profile defaults like platform and osVersion: '34', imageTag: 'google_apis_playstore', deviceMode: 'full', + deviceBaseline: 'logged-in', physical: true, }, client: createTestClient(), @@ -647,6 +739,7 @@ test('connect proxy stores the device spec as profile defaults like platform and assert.equal(generated.osVersion, '34'); assert.equal(generated.imageTag, 'google_apis_playstore'); assert.equal(generated.deviceMode, 'full'); + assert.equal(generated.deviceBaseline, 'logged-in'); assert.equal(generated.physical, true); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -667,6 +760,7 @@ test('connect proxy stores no default for a spec key that names nothing', async osVersion: '', imageTag: ' ', deviceMode: '', + deviceBaseline: ' ', physical: false, }, client: createTestClient(), diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index a403945960..ffba5255a3 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, --image-tag, --device-mode or --physical requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, + `${options.command} with --os, --image-tag, --device-mode, --device-baseline or --physical requires --platform or --lease-backend so the proxy backend knows which device family to pick from.`, ); } return { state: options.state, allocateBySpec: true }; @@ -110,8 +110,8 @@ async function resolveProxyLeaseStateLocally(options: { * none was supplied from outside, so it is refused and never released. * * A request that names a device spec has no such fallback: a new lease that does not echo the - * granted device in `lease.device`, or that reports a slim simulator for a `full` request, - * did not honour the spec, so it is released and the request fails with `device-spec-unsupported`. + * granted device in `lease.device`, that reports a slim simulator for a `full` request, or that + * does not report the requested baseline in `lease.device.baseline`, did not honour the spec, so it is released and the request fails with `device-spec-unsupported`. * A lease that reports no mode is accepted: the mode is then unconfirmed. A renewed lease serves the request only when * every spec key the request names equals the one the lease was allocated by. Once a lease is bound, * the spec leaves the command flags: the device it chose is named by its identity instead. @@ -167,6 +167,9 @@ export async function bindLeaseNamedDevice(options: { /** A refused allocation is discarded unless the daemon explicitly reported reuse. */ function leaseHonoursSpec(lease: Lease, spec: LeaseDeviceSpec): boolean { if (!lease.device) return false; + if (spec.deviceBaseline !== undefined && lease.device.baseline?.name !== spec.deviceBaseline) { + return false; + } // Full is a guarantee and slim is best effort, so a full grant serves a slim request. return !(spec.deviceMode === 'full' && lease.device.deviceMode === 'slim'); } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index cb178f6527..cc65933b11 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -570,10 +570,10 @@ Direct proxy flow for a remote Mac/simulator: Leased sessions: a connection from connect proxy, connect (cloud), another connect provider, or a remote config that sets leaseProvider leases its device for one agent. Every command on it, from connect to disconnect, names its session with --session , AGENT_DEVICE_SESSION, or a config session. Without one, it fails with INVALID_ARGS, reason REMOTE_SESSION_REQUIRED, before anything is leased, and never falls back to the connection another agent on the machine saved last. Set AGENT_DEVICE_SESSION once per agent or CI job before connect. Proxy lease by device spec: - Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , and --physical add a device spec to that request. A non-blank osVersion, imageTag or deviceMode and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, and --device-mode full asks for an unslimmed iOS simulator). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. + Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , --device-mode , --device-baseline , and --physical add a device spec to that request. A non-blank osVersion, imageTag, deviceMode or deviceBaseline and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level, --device-mode full asks for an unslimmed iOS simulator, and --device-baseline names a saved device state the backend restores; it is unrelated to diff --baseline). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore - A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, and deviceMode when it reports one); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, or with deviceMode slim for a full request, the CLI releases that lease, unless the allocation explicitly reports reuse (created: false). The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether a release it attempted succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. + A backend that honours the spec returns the granted device as lease.device (model, osVersion, imageTag, physical, deviceMode when it reports one, and baseline {name, version} when one was restored); open --json prints it. A lease that reports no deviceMode is accepted without confirming the mode. A daemon, local or behind agent-device proxy, refuses a request that names a spec key with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.keys, and records no lease. When the backend returns a lease without lease.device, with deviceMode slim for a full request, or without baseline.name equal to the requested --device-baseline, the CLI releases that lease, unless the allocation explicitly reports reuse (created: false). The command then fails with UNSUPPORTED_OPERATION, reason device-spec-unsupported, and details.released reports whether a release it attempted succeeded. A live lease serves a later command only when every spec key the command names equals the one the lease was allocated by; otherwise the command fails with reason CONNECTION_DEVICE_CONFLICT. Run connect --force to lease by another spec. Human takeover of a leased remote device: Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a6858286bb..90a8777a80 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -60,13 +60,14 @@ 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, iOS simulator mode, and physical or virtual class. Pass `--os `, `--image-tag `, `--device-mode `, or `--physical` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. A non-blank `osVersion`, `imageTag` or `deviceMode` and `physical: true` name a spec key and are sent as given. An empty or blank string and `physical: false` name nothing, from the CLI, the SDK, or MCP. A value of another type (for example `physical: "yes"`) fails with `INVALID_ARGS` and `details.reason: "device-spec-invalid"`. The service validates the values themselves (for example, Android `--os` is an API level). `connect proxy` stores the spec keys it names as defaults like `--platform` and `--device`. `--target` stays the device class selector. +A service in front of the daemon, such as simlock, can also pick the device by OS version, Android image tag, iOS simulator mode, saved device state (baseline), and physical or virtual class. Pass `--os `, `--image-tag `, `--device-mode `, `--device-baseline `, or `--physical` to `open`, `install`, `reinstall`, or `uninstall`, and the CLI sends them with `--platform`, `--target`, and `--device` on the lease request. A non-blank `osVersion`, `imageTag`, `deviceMode` or `deviceBaseline` and `physical: true` name a spec key and are sent as given. An empty or blank string and `physical: false` name nothing, from the CLI, the SDK, or MCP. A value of another type (for example `physical: "yes"`) fails with `INVALID_ARGS` and `details.reason: "device-spec-invalid"`. The service validates the values themselves (for example, Android `--os` is an API level). `connect proxy` stores the spec keys it names as defaults like `--platform` and `--device`. `--target` stays the device class selector. ```bash agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" agent-device open MyApp --platform android --os 34 --image-tag google_apis_playstore agent-device open MyApp --platform ios --physical --os 18.6.2 agent-device open MyApp --platform ios --device-mode full +agent-device open MyApp --platform ios --device-baseline logged-in ``` | Flag | Key on `agent_device.lease.allocate` | Type | @@ -74,13 +75,16 @@ agent-device open MyApp --platform ios --device-mode full | `--os ` | `osVersion` | string | | `--image-tag ` | `imageTag` | string | | `--device-mode ` | `deviceMode` | string | +| `--device-baseline ` | `deviceBaseline` | string | | `--physical` | `physical` | `true` | With `--physical`, the service leases one of the real phones enrolled with it instead of a simulator or emulator. A service that honours the spec returns the granted device on the lease as `lease.device`: `model`, `osVersion` (the exact OS granted), `imageTag` when one applies, and `physical`. `open --json` prints it under `lease.device`. A service can also report the granted simulator mode there as `deviceMode`. A lease that reports `slim` for `--device-mode full` fails the command with reason `device-spec-unsupported`, and is released unless the daemon explicitly reported `created:false` (reuse). `slim` is best effort, so a `full` simulator can serve `--device-mode slim`. A lease that reports no mode is accepted, so `--device-mode` needs a service that supports the key: one that ignores it and reports no mode leases its default mode without an error. -The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the four keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. +`--device-baseline ` asks the service to restore a saved, prepared device state on the leased device. It is unrelated to `diff screenshot --baseline `. The service reports the restored state as `lease.device.baseline` (`name` and a positive integer `version`). A new lease whose `baseline.name` is not the requested name fails the command with reason `device-spec-unsupported`, and is released unless the daemon explicitly reported `created:false` (reuse). + +The daemon itself cannot pick a device by these keys, so it refuses a named key instead of ignoring it. A local daemon, or one behind a plain `agent-device proxy`, fails such a request with `UNSUPPORTED_OPERATION`, `details.reason: "device-spec-unsupported"`, and `details.keys` naming the keys it got, and records no lease. When a service returns a lease without `lease.device`, the service did not understand the spec. The CLI discards that allocate response and fails with `UNSUPPORTED_OPERATION` and `details.reason: "device-spec-unsupported"`. It releases a discarded lease unless the daemon explicitly reported `created:false` (reuse). This also applies when a grant conflicts with `--udid` or `--serial`, or when a keyless lease is replaced by the locally selected device. `details.released`, when present, reports whether the lease was released. If release throws while discarding a refused or conflicting allocation and the daemon confirmed `created:true`, the command retries that release once. A retry that releases the lease records `released:true`. Keyless fallback stops if discarding the replaced lease throws, to avoid leaving an unnamed lease while allocating another. A failed discard or rollback uses one `details.leaseRollback` shape: it names the lease and carries `released: false`, reason `LEASE_RELEASE_FAILED`, and the release error alongside the original command error. An older daemon that omits creation evidence still has refused or replaced leases released. Once its grant is bound, a later preparation failure leaves that unrecorded lease until expiry because rollback requires `created:true`. While a lease is active, a later command that names a different value for any of the five keys fails with reason `CONNECTION_DEVICE_CONFLICT`; a key the command leaves out keeps the lease's value. Run `connect --force` to lease by another spec. ## Download captures