Skip to content
Draft
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
18 changes: 18 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,21 @@ A daemon policy `leases.require` (ADR 0029) refuses requests that name no lease.
The proxy token is one credential for every client.
Thus, a host that serves several clients through one proxy authenticates each client.
The host sets the tenant, session isolation, and lease of that client on every request that it forwards.

## Device spec

A proxy lease can name a device spec beside platform, target, and device: `osVersion` and `imageTag` on `agent_device.lease.allocate`.
Only a backend in front of the daemon, such as simlock, can pick a device by these keys.
A daemon cannot pick by them.
If a daemon ignored them, a request would run on a device that it did not ask for.
Thus, a daemon refuses every request that names either key, before admission and before a lease is recorded.
The refusal is `UNSUPPORTED_OPERATION` with `details.reason: 'device-spec-unsupported'` and `details.keys`.
This applies to a local daemon and to a daemon behind `agent-device proxy`.
The backend that honours the spec strips the keys before it forwards the allocation, and echoes the granted device on the lease as `lease.device` (`model`, `osVersion`, `imageTag?`).
`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.
24 changes: 24 additions & 0 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: true,
},
{
key: 'osVersion',
names: ['--os'],
type: 'string',
usageLabel: '--os <version|range>',
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 <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: 'stale',
names: ['--stale'],
Expand Down
3 changes: 3 additions & 0 deletions packages/command-registry/src/flag-groups.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LEASE_DEVICE_SPEC_KEYS } from '@agent-device/contracts/lease-scope';
import type { FlagKey } from './flag-types.ts';

function flagKeys<const TKeys extends readonly FlagKey[]>(...keys: TKeys): TKeys {
Expand Down Expand Up @@ -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',
Expand Down
35 changes: 35 additions & 0 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
leaseScopeToRequestMeta,
parseLeaseDeviceKey,
readLeaseAllocateProviderFlags,
readLeaseDevice,
readLeaseDeviceSpec,
} from '../lease-scope.ts';

test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => {
Expand Down Expand Up @@ -245,6 +247,39 @@ 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',
}),
{ platform: 'android', osVersion: '34', imageTag: 'google_apis_playstore' },
);
// A malformed key still reaches the daemon, which refuses it rather than never seeing it.
assert.deepEqual(readLeaseAllocateProviderFlags({ osVersion: 26, imageTag: false }), {
osVersion: 26,
imageTag: false,
});
});

test('readLeaseDeviceSpec names only a non-empty version or tag', () => {
const padded = { osVersion: ' >=26 ', imageTag: '' };
assert.deepEqual(readLeaseDeviceSpec(padded), { osVersion: '>=26' });
assert.deepEqual(readLeaseDeviceSpec({ imageTag: 'google_apis' }), { imageTag: 'google_apis' });
assert.deepEqual(readLeaseDeviceSpec({}), {});
});

test('readLeaseDevice reads the granted facts and rejects an echo missing one', () => {
const pixel = { model: 'Pixel 9', osVersion: '34', imageTag: 'google_apis' };
assert.deepEqual(readLeaseDevice(pixel), pixel);
const iphone = { model: 'iPhone 17 Pro', osVersion: '26.0' };
assert.deepEqual(readLeaseDevice(iphone), iphone);
assert.equal(readLeaseDevice({ model: 'iPhone 17 Pro' }), undefined);
assert.equal(readLeaseDevice({ osVersion: '26.0' }), 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(
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ 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;
stale?: boolean;
iosSimulatorDeviceSet?: string;
iosXctestrunFile?: string;
Expand Down
17 changes: 11 additions & 6 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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;
};
Expand All @@ -62,7 +65,8 @@ export type AppDeployResult = {
};

export type AppOpenOptions = AgentDeviceRequestOverrides &
AgentDeviceSelectionOptions & {
AgentDeviceSelectionOptions &
LeaseDeviceSpec & {
app?: string;
url?: string;
surface?: SessionSurface;
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 47 additions & 21 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,38 @@ 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;
heartbeatAt?: number;
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;
};

/** The device facts the backend granted for a {@link LeaseDeviceSpec}. */
export type LeaseDevice = {
model: string;
/** The exact OS version granted. */
osVersion: string;
imageTag?: string;
};

/**
* 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`,
Expand All @@ -35,27 +60,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;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type {
HumanControlHoldScope,
Lease,
LeaseAllocateOptions,
LeaseDevice,
LeaseDeviceSpec,
LeaseOptions,
LeaseScopedOptions,
} from '../client-lease.ts';
Expand Down
84 changes: 80 additions & 4 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -263,6 +264,22 @@ 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',
] 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 =
Expand All @@ -272,20 +289,79 @@ type LeaseAllocateProfileKeysAreExhaustive =
const leaseAllocateProfileKeysExhaustive: LeaseAllocateProfileKeysAreExhaustive = true;
void leaseAllocateProfileKeysExhaustive;

type LeaseAllocateProviderFlags = Partial<Pick<CommandFlags, LeaseAllocateProviderFlagKey>>;
type LeaseAllocateProviderFlags = Partial<
Pick<CommandFlags, LeaseAllocateProviderFlagKey | LeaseDeviceSpecKey>
>;

/** 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<string, unknown> | undefined,
): LeaseAllocateProviderFlags {
const flags: Record<string, string | boolean> = {};
const flags: Record<string, unknown> = {};
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<string, unknown> | 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. */
export function readLeaseDeviceSpec(source: {
osVersion?: unknown;
imageTag?: unknown;
}): LeaseDeviceSpec {
return stripUndefined({
osVersion: nonEmptyString(source.osVersion),
imageTag: nonEmptyString(source.imageTag),
Comment on lines +326 to +328
});
}

/** 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<string, unknown>;
const model = nonEmptyString(record.model);
const osVersion = nonEmptyString(record.osVersion);
if (!model || !osVersion) return undefined;
const imageTag = nonEmptyString(record.imageTag);
return {
model,
osVersion,
...(imageTag ? { imageTag } : {}),
};
}

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

function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}

export function leaseScopeToConnectionMetadata(
scope: LeaseScope,
): Pick<LeaseScope, 'leaseProvider' | 'deviceKey' | 'clientId'> | undefined {
Expand Down
Loading
Loading