Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions packages/client/src/__tests__/index-leases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
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 @@ -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 <name>',
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'],
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 @@ -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(
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' | 'deviceMode' | 'physical'> {
): Pick<CliFlags, 'osVersion' | 'imageTag' | 'deviceMode' | 'deviceBaseline' | '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 @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions packages/command-surface/src/command-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -433,13 +436,12 @@ export function leaseDeviceSpecFields() {
osVersion: leaseDeviceSpecField('osVersion'),
imageTag: leaseDeviceSpecField('imageTag'),
deviceMode: leaseDeviceSpecField('deviceMode'),
deviceBaseline: leaseDeviceSpecField('deviceBaseline'),
physical: leaseDeviceSpecField('physical'),
};
}

function leaseDeviceSpecField<TKey extends 'osVersion' | 'imageTag' | 'deviceMode' | 'physical'>(
key: TKey,
) {
function leaseDeviceSpecField<TKey extends (typeof LEASE_DEVICE_SPEC_KEYS)[number]>(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 @@ -34,6 +34,7 @@ const ENV_EXCLUDED_FLAG_KEYS = new Set<FlagKey>([
'osVersion',
'imageTag',
'deviceMode',
'deviceBaseline',
'physical',
]);

Expand Down
45 changes: 40 additions & 5 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,27 +275,46 @@ 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: [],
},
);
const notNamed = { spec: {}, keys: [], invalidKeys: [] };
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 }), {
Expand Down Expand Up @@ -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(
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 @@ -66,6 +66,7 @@ export type CliFlags = CloudProviderProfileFields &
osVersion?: string;
imageTag?: string;
deviceMode?: string;
deviceBaseline?: 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 @@ -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;
};
Expand All @@ -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;
};

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

Expand Down Expand Up @@ -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<string, string | boolean> = {};
Expand Down Expand Up @@ -371,34 +379,47 @@ 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<string, unknown>;
const name = nonEmptyString(record.name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: readLeaseDevice trims baseline.name, but device-spec requests preserve names as given; a valid echoed name with surrounding whitespace is therefore rejected as a mismatch. Preserve the original string while only trimming to test whether it is blank.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/lease-scope.ts, line 396:

<comment>`readLeaseDevice` trims `baseline.name`, but device-spec requests preserve names as given; a valid echoed name with surrounding whitespace is therefore rejected as a mismatch. Preserve the original string while only trimming to test whether it is blank.</comment>

<file context>
@@ -371,34 +379,47 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined {
+function readLeaseDeviceBaseline(value: unknown): LeaseDevice['baseline'] {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+  const record = value as Record<string, unknown>;
+  const name = nonEmptyString(record.name);
+  const { version } = record;
+  if (!name || typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
</file context>
Suggested change
const name = nonEmptyString(record.name);
const name =
typeof record.name === 'string' && record.name.trim().length > 0 ? record.name : undefined;

const { version } = record;
if (!name || typeof version !== 'number' || !Number.isInteger(version) || version < 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Number.isInteger accepts unsafe positive numbers, so malformed baseline echoes can expose an unreliable version as valid. Require Number.isSafeInteger for the parsed version.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/lease-scope.ts, line 398:

<comment>`Number.isInteger` accepts unsafe positive numbers, so malformed baseline echoes can expose an unreliable version as valid. Require `Number.isSafeInteger` for the parsed version.</comment>

<file context>
@@ -371,34 +379,47 @@ export function readLeaseDevice(value: unknown): LeaseDevice | undefined {
+  const record = value as Record<string, unknown>;
+  const name = nonEmptyString(record.name);
+  const { version } = record;
+  if (!name || typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
+    return undefined;
+  }
</file context>
Suggested change
if (!name || typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
if (!name || typeof version !== 'number' || !Number.isSafeInteger(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<string, unknown> = {},
): 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.',
});
}

/** 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, 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] },
);
}
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 @@ -152,6 +152,7 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions &
osVersion?: string;
imageTag?: string;
deviceMode?: string;
deviceBaseline?: 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 @@ -71,6 +71,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
osVersion?: string;
imageTag?: string;
deviceMode?: string;
deviceBaseline?: string;
physical?: boolean;
activity?: string;
launchConsole?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
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 @@ -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',
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', 'deviceMode', 'physical'],
keys: ['osVersion', 'imageTag', 'deviceMode', 'deviceBaseline', 'physical'],
},
{
name: 'cloud artifact provider lookup',
Expand Down
Loading
Loading