Skip to content
Merged
14 changes: 10 additions & 4 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,23 +329,29 @@ The host sets the tenant, session isolation, and lease of that client on every r

## Device spec

A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, and `physical` on `agent_device.lease.allocate`.
A proxy lease can name a device spec beside platform, target, and device: `osVersion`, `imageTag`, `deviceMode`, and `physical` on `agent_device.lease.allocate`.
Only a backend in front of the daemon, such as simlock, can pick a device by these keys.
A daemon cannot pick by them.
If a daemon ignored them, a request would run on a device that it did not ask for.
Thus, a daemon refuses every request that names a non-blank `osVersion` or `imageTag`, or `physical: true`, before admission and before a lease is recorded.
Thus, a daemon refuses every request that names a non-blank `osVersion`, `imageTag` or `deviceMode`, or `physical: true`, before admission and before a lease is recorded.
The refusal is `UNSUPPORTED_OPERATION` with `details.reason: 'device-spec-unsupported'` and `details.keys`.
This applies to a local daemon and to a daemon behind `agent-device proxy`.
A blank `osVersion` or `imageTag` and `physical: false` name nothing, so a daemon accepts them.
A blank `osVersion`, `imageTag` or `deviceMode` and `physical: false` name nothing, so a daemon accepts them.
`deviceMode` (`slim` or `full`) chooses how much of an iOS simulator the backend keeps running; the backend validates the value.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
A named value is used as given, without trimming.
A value of another type is refused with `INVALID_ARGS`, `details.reason: 'device-spec-invalid'`, and `details.keys`.
`readLeaseDeviceSpec` is the one decision on these rules, for CLI flags, the SDK, MCP, and the daemon.
A remote config file first applies the value rules of every config key: a string must be non-empty, and a boolean also accepts a boolean string such as `"true"`.
The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `physical`).
The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`, `deviceMode?`, `physical`).
`deviceKey` stays the identity of the lease; `lease.device` is the proof that the spec was understood.
A backend can report the granted mode as `lease.device.deviceMode`.
The CLI proxy connection treats a new lease that reports `slim` for a `full` request like a lease without `lease.device`.
Full is a guarantee and slim is best effort, so `full` granted for a `slim` request is served.
A lease that reports no mode is accepted, so the proof does not confirm `deviceMode`: a backend that ignores the key and reports no mode grants its default without an error.
A backend older than the spec returns a lease without `lease.device`.
The CLI proxy connection then releases that lease and fails with `UNSUPPORTED_OPERATION` and `details.reason: 'device-spec-unsupported'`.
`details.released` reports whether the release succeeded.
An allocation that explicitly reports reuse (`created: false`) fails with the same reason, without a release and without `details.released`.
A live lease serves a later command only when every spec key that the command names equals the one the lease was allocated by; otherwise the command fails with `CONNECTION_DEVICE_CONFLICT`.
The client never picks a device by spec from the daemon inventory.
`--target` stays the only class selector.
Expand Down
30 changes: 30 additions & 0 deletions packages/client/src/__tests__/index-leases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,33 @@ test('lease allocation sends the device spec and returns the device the backend
physical: false,
});
});

test('lease allocation sends the iOS simulator mode and returns the mode the backend granted', async () => {
const device = { model: 'iPhone 17 Pro', osVersion: '26.2', deviceMode: 'full', physical: false };
const setup = createClientTransport(async (req) => ({
ok: true,
data: {
lease: {
leaseId: 'lease-mode',
tenantId: req.meta?.tenantId,
runId: req.meta?.runId,
backend: req.meta?.leaseBackend,
deviceKey: 'ios:mobile:SIM-1',
device,
},
},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const lease = await client.leases.allocate({
tenant: 'proxy',
runId: 'proxy-run',
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
platform: 'ios',
deviceMode: 'full',
});

assert.equal(setup.calls[0]?.flags?.deviceMode, 'full');
assert.deepEqual(lease.device, device);
});
12 changes: 12 additions & 0 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: false,
recorded: false,
},
{
key: 'deviceMode',
names: ['--device-mode'],
type: 'string',
usageLabel: '--device-mode <slim|full>',
usageDescription:
'Proxy lease: iOS simulator mode (slim or full) the backend leases; refused without such a backend',
inputDescription:
'iOS simulator mode for the device a proxy backend leases, slim or full, sent as given; the backend validates it. A blank string names nothing; a non-string fails with INVALID_ARGS, reason device-spec-invalid. Only the CLI allocating a lease through connect proxy sends it to that backend; a daemon refuses every other request that names it, with reason device-spec-unsupported.',
projectConfig: false,
recorded: false,
},
{
key: 'physical',
names: ['--physical'],
Expand Down
1 change: 1 addition & 0 deletions packages/command-registry/src/flag-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const METRO_RELOAD_FLAGS = flagKeys('metroHost', 'metroPort', 'bundleUrl'
export const LEASE_DEVICE_SPEC_FLAGS = flagKeys(
'osVersion',
'imageTag',
'deviceMode',
'physical',
) satisfies typeof LEASE_DEVICE_SPEC_KEYS;
export const REPEATED_TOUCH_FLAGS = flagKeys(
Expand Down
2 changes: 1 addition & 1 deletion packages/command-surface/src/cli-grammar/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function repeatedInputFromFlags(flags: CliFlags): Record<string, unknown>
/** The spec keys the flags name, as `requireLeaseDeviceSpec` decides them; an invalid key throws. */
export function leaseDeviceSpecInputFromFlags(
flags: CliFlags,
): Pick<CliFlags, 'osVersion' | 'imageTag' | 'physical'> {
): Pick<CliFlags, 'osVersion' | 'imageTag' | 'deviceMode' | 'physical'> {
return requireLeaseDeviceSpec(flags).spec;
}

Expand Down
1 change: 1 addition & 0 deletions packages/command-surface/src/command-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags {
androidDeviceAllowlist: options.androidDeviceAllowlist,
osVersion: options.osVersion,
imageTag: options.imageTag,
deviceMode: options.deviceMode,
physical: options.physical,
surface: options.surface,
activity: options.activity,
Expand Down
5 changes: 4 additions & 1 deletion packages/command-surface/src/command-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,11 +432,14 @@ export function leaseDeviceSpecFields() {
return {
osVersion: leaseDeviceSpecField('osVersion'),
imageTag: leaseDeviceSpecField('imageTag'),
deviceMode: leaseDeviceSpecField('deviceMode'),
physical: leaseDeviceSpecField('physical'),
};
}

function leaseDeviceSpecField<TKey extends 'osVersion' | 'imageTag' | 'physical'>(key: TKey) {
function leaseDeviceSpecField<TKey extends 'osVersion' | 'imageTag' | 'deviceMode' | 'physical'>(
key: TKey,
) {
return customField(
optionField(key).schema,
(record) => requireLeaseDeviceSpec({ [key]: record[key] }).spec[key],
Expand Down
1 change: 1 addition & 0 deletions packages/command-surface/src/schema/option-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const ENV_EXCLUDED_FLAG_KEYS = new Set<FlagKey>([
// The proxy lease device spec comes from a flag or `connect proxy`, never ambient environment.
'osVersion',
'imageTag',
'deviceMode',
'physical',
]);

Expand Down
26 changes: 17 additions & 9 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,25 +271,33 @@ test('readLeaseAllocateProviderFlags carries a named or invalid spec key as give

test('readLeaseDeviceSpec decides each key is named, not named, or invalid', () => {
assert.deepEqual(
readLeaseDeviceSpec({ osVersion: '>=26', imageTag: 'google_apis', physical: true }),
readLeaseDeviceSpec({
osVersion: '>=26',
imageTag: 'google_apis',
deviceMode: 'full',
physical: true,
}),
{
spec: { osVersion: '>=26', imageTag: 'google_apis', physical: true },
keys: ['osVersion', 'imageTag', 'physical'],
spec: { osVersion: '>=26', imageTag: 'google_apis', deviceMode: 'full', physical: true },
keys: ['osVersion', 'imageTag', 'deviceMode', 'physical'],
invalidKeys: [],
},
);
const notNamed = { spec: {}, keys: [], invalidKeys: [] };
assert.deepEqual(readLeaseDeviceSpec(undefined), notNamed);
assert.deepEqual(readLeaseDeviceSpec({}), notNamed);
assert.deepEqual(
readLeaseDeviceSpec({ osVersion: '', imageTag: ' ', physical: false }),
readLeaseDeviceSpec({ osVersion: '', imageTag: ' ', deviceMode: '', physical: false }),
notNamed,
);
assert.deepEqual(readLeaseDeviceSpec({ osVersion: 26, imageTag: null, physical: 'yes' }), {
spec: {},
keys: [],
invalidKeys: ['osVersion', 'imageTag', 'physical'],
});
assert.deepEqual(
readLeaseDeviceSpec({ osVersion: 26, imageTag: null, deviceMode: true, physical: 'yes' }),
{
spec: {},
keys: [],
invalidKeys: ['osVersion', 'imageTag', 'deviceMode', 'physical'],
},
);
assert.deepEqual(readLeaseDeviceSpec({ osVersion: '18', physical: 1 }), {
spec: { osVersion: '18' },
keys: ['osVersion'],
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type CliFlags = CloudProviderProfileFields &
// that names any of them, as `readLeaseDeviceSpec` decides.
osVersion?: string;
imageTag?: string;
deviceMode?: string;
physical?: boolean;
stale?: boolean;
iosSimulatorDeviceSet?: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type LeaseDeviceSpec = {
osVersion?: string;
/** Android system image tag, for example `google_apis_playstore`. */
imageTag?: string;
/** iOS simulator mode, `slim` or `full`; the backend validates it. */
deviceMode?: string;
/** `true` asks for a physical device instead of a simulator or emulator. */
physical?: boolean;
};
Expand All @@ -53,6 +55,8 @@ export type LeaseDevice = {
/** The exact OS version granted. */
osVersion: string;
imageTag?: string;
/** The iOS simulator mode granted, when the backend reports it. */
deviceMode?: string;
physical: boolean;
};

Expand Down
15 changes: 10 additions & 5 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[n
export const LEASE_DEVICE_SPEC_KEYS = [
'osVersion',
'imageTag',
'deviceMode',
'physical',
] as const satisfies readonly (keyof CommandFlags & keyof LeaseDeviceSpec)[];

Expand Down Expand Up @@ -330,12 +331,14 @@ export type LeaseDeviceSpecReading = {

/**
* The one decision on what a device spec names, for the CLI, the SDK, MCP and the daemon alike.
* Each key is named, not named, or invalid: a non-blank `osVersion` or `imageTag` and
* Each key is named, not named, or invalid: a non-blank `osVersion`, `imageTag` or `deviceMode` and
* `physical: true` are named, with the value as given; an absent key, a blank string and
* `physical: false` name nothing; any other type is invalid.
*/
export function readLeaseDeviceSpec(
source: { osVersion?: unknown; imageTag?: unknown; physical?: unknown } | undefined,
source:
| { osVersion?: unknown; imageTag?: unknown; deviceMode?: unknown; physical?: unknown }
| undefined,
): LeaseDeviceSpecReading {
const spec: Record<string, string | boolean> = {};
const invalidKeys: LeaseDeviceSpecKey[] = [];
Expand Down Expand Up @@ -367,33 +370,35 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined {
const osVersion = nonEmptyString(record.osVersion);
if (!model || !osVersion || typeof record.physical !== 'boolean') return undefined;
const imageTag = nonEmptyString(record.imageTag);
const deviceMode = nonEmptyString(record.deviceMode);
return {
model,
osVersion,
...(imageTag ? { imageTag } : {}),
...(deviceMode ? { deviceMode } : {}),
physical: record.physical,
};
}

/** The refusal for a request whose device spec nothing on its path can honour. */
export function deviceSpecUnsupportedError(
keys: readonly string[],
message = 'This daemon cannot pick a device by OS version, image tag, or physical/virtual class.',
message = 'This daemon cannot pick a device by OS version, image tag, device mode, or physical/virtual class.',
details: Record<string, unknown> = {},
): AppError {
return new AppError('UNSUPPORTED_OPERATION', message, {
...details,
reason: DEVICE_SPEC_UNSUPPORTED_REASON,
keys: [...keys],
hint: 'Drop --os, --image-tag and --physical, or connect through a proxy whose backend picks devices by spec.',
hint: 'Drop --os, --image-tag, --device-mode and --physical, or connect through a proxy whose backend picks devices by spec.',
});
}

/** The refusal for a request giving a device-spec key a value of the wrong type. */
export function deviceSpecInvalidError(keys: readonly string[]): AppError {
return new AppError(
'INVALID_ARGS',
'osVersion and imageTag take a string, and physical takes a boolean.',
'osVersion, imageTag and deviceMode take a string, and physical takes a boolean.',
{ reason: DEVICE_SPEC_INVALID_REASON, keys: [...keys] },
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/remote-config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions &
serial?: string;
osVersion?: string;
imageTag?: string;
deviceMode?: string;
physical?: boolean;
iosSimulatorDeviceSet?: string;
androidDeviceAllowlist?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
surface?: SessionSurface;
osVersion?: string;
imageTag?: string;
deviceMode?: string;
physical?: boolean;
activity?: string;
launchConsole?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ test('a command naming a spec is refused with device-spec-unsupported before it
expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled();
});

test('a command naming a device mode is refused with device-spec-unsupported before it runs', async () => {
const response = await sendAppSwitcher({ deviceMode: 'full' });

expect(response).toMatchObject({
ok: false,
error: {
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'device-spec-unsupported', keys: ['deviceMode'] },
},
});
expect(systemRuntimeSpies.appSwitcher).not.toHaveBeenCalled();
});

test('a command giving a spec key the wrong type is refused with device-spec-invalid before it runs', async () => {
// The named osVersion would be refused as unsupported; the invalid key is reported instead.
const response = await sendAppSwitcher({
Expand Down
1 change: 1 addition & 0 deletions packages/remote/src/remote-config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [
// The device spec only a proxy backend honours; no environment variable sets it.
{ key: 'osVersion', type: 'string', env: false },
{ key: 'imageTag', type: 'string', env: false },
{ key: 'deviceMode', type: 'string', env: false },
{ key: 'physical', type: 'boolean', env: false },
{
key: 'iosSimulatorDeviceSet',
Expand Down
2 changes: 1 addition & 1 deletion scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ function summarizeProviderScenarioFlagExclusions() {
// The device spec is honoured by a backend in front of the daemon; the daemon only refuses it.
name: 'proxy lease device spec',
owner: 'proxy allocate-by-spec, request-router refusal, and lease-scope tests',
keys: ['osVersion', 'imageTag', 'physical'],
keys: ['osVersion', 'imageTag', 'deviceMode', 'physical'],
},
{
name: 'cloud artifact provider lookup',
Expand Down
Loading
Loading