feat: add device-aware lease contracts

This commit is contained in:
Michał Pierzchała
2026-06-26 12:51:53 +02:00
parent c593ecb04d
commit 11dd8be6ae
15 changed files with 836 additions and 45 deletions
+11
View File
@@ -14,6 +14,17 @@
- Target: selected automation destination, such as mobile, tv, or desktop.
- Modality: broad supported device family, such as mobile, tv, or desktop.
- Session: daemon-owned state for a selected target and opened app or surface.
- Device lease: logical remote ownership of one selected device for a
tenant/run/client and lease provider, separate from platform helper process
locking.
- Device key: stable provider-scoped device identity used for lease contention,
such as a simulator UDID, physical device id, or provider inventory id.
- Lease provider: remote connection source that routes and owns a device lease,
such as `proxy`, cloud bridge, or `limrun`.
- Direct proxy client id: optional remote proxy client identity used to bind
lease activity to the agent that acquired the selected device.
- Runner/process lease: backend helper mutual-exclusion guard for platform
runners or tools; it is not the remote client ownership boundary.
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type.
+47
View File
@@ -0,0 +1,47 @@
# ADR 0007: Remote Device Leases
## Status
Accepted
## Context
Remote daemon users need a clear ownership boundary before a command reaches a
platform runner or helper. The existing lease model can bind a tenant/run to a
backend, but direct proxy and hosted providers also need to identify the selected
device and the connection provider that owns it.
Runner and helper processes already have backend-specific mutual exclusion. That
guard protects platform tooling, not remote client ownership, so surfacing those
errors directly makes device contention harder to recover from.
## Decision
A remote device lease is logical ownership of one selected device by one remote
agent/client for a connection provider such as `proxy`, a cloud bridge, or
`limrun`.
`connect` establishes a connection profile and client identity. Lease allocation
remains lazy and happens only when a device, backend, and provider are known.
A runner/process lease is a backend helper guard and is not a user/client
ownership boundary. It stays below daemon device leases and should not be
weakened or replaced by them.
`open` is the natural point to acquire a device lease because target resolution
and session creation meet there. Commands after `open` must refresh the lease;
no activity for five minutes should make the device available again.
The proxy process is expected to be long-lived and self-serve. Recovery from a
stale or expired device lease should not require restarting the proxy.
## Consequences
Device contention can fail before platform execution with an explicit
device-lease error that includes the backend, provider, selected device key, and
owning lease expiry.
Backend-only leases remain valid for older remote clients. Device and provider
fields are optional until provider-aware `open` acquisition and admission
refreshes are implemented.
+19
View File
@@ -72,6 +72,9 @@ export type AgentDeviceClientConfig = {
runId?: string;
leaseId?: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
runtime?: SessionRuntimeHints;
cwd?: string;
debug?: boolean;
@@ -95,6 +98,9 @@ export type AgentDeviceRequestOverrides = Pick<
| 'runId'
| 'leaseId'
| 'leaseBackend'
| 'leaseProvider'
| 'deviceKey'
| 'clientId'
| 'cwd'
| 'debug'
| 'iosXctestrunFile'
@@ -274,6 +280,10 @@ export type Lease = {
tenantId: string;
runId: string;
backend: LeaseBackend;
leaseProvider?: string;
provider?: string;
deviceKey?: string;
clientId?: string;
createdAt?: number;
heartbeatAt?: number;
expiresAt?: number;
@@ -287,12 +297,21 @@ export type LeaseAllocateOptions = LeaseOptions & {
tenant: string;
runId: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
provider?: string;
deviceKey?: string;
clientId?: string;
};
export type LeaseScopedOptions = LeaseOptions & {
tenant?: string;
runId?: string;
leaseId: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
provider?: string;
deviceKey?: string;
clientId?: string;
};
export type MetroPrepareOptions = {
+56
View File
@@ -68,6 +68,9 @@ export type DaemonRequestMeta = {
leaseId?: string;
leaseTtlMs?: number;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
sessionIsolation?: SessionIsolationMode;
uploadedArtifactId?: string;
clientArtifactPaths?: Record<string, string>;
@@ -129,6 +132,9 @@ export type LeaseAllocatePayload = {
runId?: string;
ttlMs?: number;
backend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type LeaseHeartbeatPayload = {
@@ -139,6 +145,10 @@ export type LeaseHeartbeatPayload = {
runId?: string;
leaseId?: string;
ttlMs?: number;
backend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type LeaseReleasePayload = {
@@ -148,6 +158,10 @@ export type LeaseReleasePayload = {
tenant?: string;
runId?: string;
leaseId?: string;
backend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type JsonRpcId = string | number | null;
@@ -225,6 +239,37 @@ function optionalString(
return value === undefined ? undefined : expectString(value, `${path}.${key}`);
}
function optionalDeviceKey(
record: Record<string, unknown>,
key: string,
path: string,
): string | undefined {
const value = optionalString(record, key, path);
if (value === undefined) return undefined;
const trimmed = value.trim();
if (!trimmed || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) {
fail(`${path}.${key}`, 'Expected 1-256 printable characters');
}
return value;
}
function optionalIdentifier(
record: Record<string, unknown>,
key: string,
path: string,
maxLength: number,
): string | undefined {
const value = optionalString(record, key, path);
if (value === undefined) return undefined;
if (value.length < 1 || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) {
fail(
`${path}.${key}`,
`Expected 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen`,
);
}
return value;
}
function optionalBoolean(
record: Record<string, unknown>,
key: string,
@@ -374,6 +419,9 @@ export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) =>
leaseId: optionalString(meta, 'leaseId', `${path}.meta`),
leaseTtlMs: optionalInteger(meta, 'leaseTtlMs', `${path}.meta`),
leaseBackend: optionalEnum(meta, 'leaseBackend', LEASE_BACKENDS, `${path}.meta`),
leaseProvider: optionalIdentifier(meta, 'leaseProvider', `${path}.meta`, 64),
deviceKey: optionalDeviceKey(meta, 'deviceKey', `${path}.meta`),
clientId: optionalIdentifier(meta, 'clientId', `${path}.meta`, 128),
sessionIsolation: optionalEnum(
meta,
'sessionIsolation',
@@ -431,6 +479,9 @@ function parseLeaseScope(
tenantId?: string;
tenant?: string;
runId?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
} {
return {
token: optionalString(record, 'token', path),
@@ -438,6 +489,9 @@ function parseLeaseScope(
tenantId: optionalString(record, 'tenantId', path),
tenant: optionalString(record, 'tenant', path),
runId: optionalString(record, 'runId', path),
leaseProvider: optionalIdentifier(record, 'leaseProvider', path, 64),
deviceKey: optionalDeviceKey(record, 'deviceKey', path),
clientId: optionalIdentifier(record, 'clientId', path, 128),
};
}
@@ -456,6 +510,7 @@ export const leaseHeartbeatSchema = schema<LeaseHeartbeatPayload>((input, path)
...parseLeaseScope(parsed.record, path),
leaseId: parsed.leaseId,
ttlMs: parsed.ttlMs,
backend: optionalEnum(parsed.record, 'backend', LEASE_BACKENDS, path),
};
});
@@ -467,6 +522,7 @@ export const leaseReleaseSchema = schema<LeaseReleasePayload>((input, path) => {
return {
...parseLeaseScope(record, path),
leaseId: optionalString(record, 'leaseId', path),
backend: optionalEnum(record, 'backend', LEASE_BACKENDS, path),
};
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
buildLeaseDiagnosticsContext,
buildSessionLeaseFromRequest,
resolveRequestOrSessionLeaseScope,
type SessionLease,
} from '../lease-context.ts';
import type { DaemonRequest } from '../types.ts';
test('buildSessionLeaseFromRequest captures complete request lease scope', () => {
const lease = buildSessionLeaseFromRequest({
meta: {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-1',
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
},
});
assert.deepEqual(lease, {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-1',
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
});
});
test('buildSessionLeaseFromRequest skips incomplete lease scope', () => {
assert.equal(
buildSessionLeaseFromRequest({
meta: {
tenantId: 'tenant-a',
runId: 'run-1',
},
}),
undefined,
);
});
test('resolveRequestOrSessionLeaseScope lets explicit request fields override session lease', () => {
const sessionLease: SessionLease = {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-session',
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
};
const scope = resolveRequestOrSessionLeaseScope(
{
meta: {
leaseId: 'lease-request',
leaseProvider: 'limrun',
},
},
{ lease: sessionLease },
);
assert.deepEqual(scope, {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-request',
leaseBackend: 'ios-instance',
leaseProvider: 'limrun',
deviceKey: 'device-1',
clientId: 'client-a',
});
});
test('resolveRequestOrSessionLeaseScope accepts deviceLease as session compatibility input', () => {
const scope = resolveRequestOrSessionLeaseScope({} satisfies Partial<DaemonRequest>, {
deviceLease: {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-session',
},
});
assert.deepEqual(scope, {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-session',
});
});
test('buildLeaseDiagnosticsContext strips ttl and empty fields', () => {
const context = buildLeaseDiagnosticsContext({
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-1',
leaseTtlMs: 60_000,
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
assert.deepEqual(context, {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: 'lease-1',
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
assert.equal(buildLeaseDiagnosticsContext({}), undefined);
});
+145
View File
@@ -98,3 +98,148 @@ test('capacity limits reject additional simulator leases', () => {
/No simulator lease capacity available/,
);
});
test('device-aware allocation is idempotent per tenant/run/backend/provider/device', () => {
let now = 1_000;
const registry = new LeaseRegistry({
now: () => now,
defaultLeaseTtlMs: 10_000,
});
const first = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
});
now = 3_000;
const second = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
});
assert.equal(second.leaseId, first.leaseId);
assert.equal(second.leaseProvider, 'proxy');
assert.equal(second.provider, 'proxy');
assert.equal(second.deviceKey, 'device-1');
assert.equal(second.clientId, 'client-a');
assert.equal(second.heartbeatAt, 3_000);
assert.equal(second.expiresAt, 13_000);
});
test('same backend/provider/device rejects conflicting active lease', () => {
const registry = new LeaseRegistry();
const first = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
assert.throws(
() =>
registry.allocateLease({
tenantId: 'tenant-b',
runId: 'run-2',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
}),
(error) =>
error instanceof Error &&
error.message === 'Device is already leased' &&
(error as { details?: Record<string, unknown> }).details?.reason === 'DEVICE_LEASE_BUSY' &&
(error as { details?: Record<string, unknown> }).details?.leaseId === first.leaseId,
);
});
test('device leases are isolated by provider and device key', () => {
const registry = new LeaseRegistry();
const proxy = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
const limrun = registry.allocateLease({
tenantId: 'tenant-b',
runId: 'run-2',
backend: 'ios-instance',
leaseProvider: 'limrun',
deviceKey: 'device-1',
});
const secondDevice = registry.allocateLease({
tenantId: 'tenant-c',
runId: 'run-3',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-2',
});
assert.notEqual(limrun.leaseId, proxy.leaseId);
assert.notEqual(secondDevice.leaseId, proxy.leaseId);
});
test('heartbeat enforces device and provider scope when supplied', () => {
const registry = new LeaseRegistry();
const lease = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
});
assert.throws(
() => registry.heartbeatLease({ leaseId: lease.leaseId, deviceKey: 'device-2' }),
(error) =>
error instanceof Error &&
(error as { details?: Record<string, unknown> }).details?.reason === 'LEASE_SCOPE_MISMATCH',
);
assert.throws(
() => registry.heartbeatLease({ leaseId: lease.leaseId, leaseProvider: 'limrun' }),
(error) =>
error instanceof Error &&
(error as { details?: Record<string, unknown> }).details?.reason === 'LEASE_SCOPE_MISMATCH',
);
assert.throws(
() => registry.heartbeatLease({ leaseId: lease.leaseId, clientId: 'client-b' }),
(error) =>
error instanceof Error &&
(error as { details?: Record<string, unknown> }).details?.reason === 'LEASE_SCOPE_MISMATCH',
);
});
test('expired device lease releases device binding for new clients', () => {
let now = 1_000;
const registry = new LeaseRegistry({
now: () => now,
defaultLeaseTtlMs: 5_000,
});
const first = registry.allocateLease({
tenantId: 'tenant-a',
runId: 'run-1',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
now = 7_000;
const second = registry.allocateLease({
tenantId: 'tenant-b',
runId: 'run-2',
backend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
});
assert.notEqual(second.leaseId, first.leaseId);
});
@@ -90,6 +90,58 @@ test('lease handler executes commands owned by the lease route', async () => {
}
});
test('lease handler preserves device-aware lease fields', async () => {
const leaseRegistry = new LeaseRegistry();
const allocateResponse = await handleLeaseCommands({
req: {
command: INTERNAL_COMMANDS.leaseAllocate,
token: 'test-token',
session: 'catalog-test',
meta: {
tenantId: 'tenant-a',
runId: 'run-a',
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
},
positionals: [],
},
leaseRegistry,
});
assert.equal(allocateResponse?.ok, true);
const allocateLease = readLeaseResponse(allocateResponse);
assert.equal(allocateLease.deviceKey, 'device-1');
assert.equal(allocateLease.clientId, 'client-a');
assert.equal(allocateLease.leaseProvider, 'proxy');
const heartbeatResponse = await handleLeaseCommands({
req: {
command: INTERNAL_COMMANDS.leaseHeartbeat,
token: 'test-token',
session: 'catalog-test',
meta: {
tenantId: 'tenant-a',
runId: 'run-a',
leaseId: allocateLease.leaseId,
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
},
positionals: [],
},
leaseRegistry,
});
assert.equal(heartbeatResponse?.ok, true);
const heartbeatLease = readLeaseResponse(heartbeatResponse);
assert.equal(heartbeatLease.deviceKey, 'device-1');
assert.equal(heartbeatLease.clientId, 'client-a');
assert.equal(heartbeatLease.leaseProvider, 'proxy');
});
function catalogCommandsForRoute(route: Exclude<DaemonCommandRoute, 'generic'>): string[] {
return [...Object.values(PUBLIC_COMMANDS), ...Object.values(INTERNAL_COMMANDS)].filter(
(command) => getDaemonCommandRoute(command) === route,
@@ -154,3 +206,13 @@ function assertNoRoutingMismatch(error: unknown, command: string): void {
assert.ok(error instanceof Error, `${command} threw a non-error value`);
assert.doesNotMatch(error.message, new RegExp(ROUTING_MISMATCH_MESSAGE), command);
}
function readLeaseResponse(response: DaemonResponse | null): Record<string, unknown> & {
leaseId: string;
} {
assert.ok(response?.ok);
const lease = response.data?.lease;
assert.ok(lease && typeof lease === 'object' && !Array.isArray(lease));
assert.equal(typeof (lease as Record<string, unknown>).leaseId, 'string');
return lease as Record<string, unknown> & { leaseId: string };
}
+11
View File
@@ -16,6 +16,9 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
tenantId: leaseScope.tenantId ?? '',
runId: leaseScope.runId ?? '',
backend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
ttlMs: leaseScope.leaseTtlMs,
});
return {
@@ -28,6 +31,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
leaseId: leaseScope.leaseId ?? '',
tenantId: leaseScope.tenantId,
runId: leaseScope.runId,
backend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
ttlMs: leaseScope.leaseTtlMs,
});
return {
@@ -40,6 +47,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
leaseId: leaseScope.leaseId ?? '',
tenantId: leaseScope.tenantId,
runId: leaseScope.runId,
backend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
});
return {
ok: true,
+4
View File
@@ -282,6 +282,10 @@ function toLeaseDaemonRequest(
leaseId: readStringParam(params, 'leaseId'),
leaseTtlMs: readIntParam(params, 'ttlMs'),
leaseBackend: readStringParam(params, 'backend') as LeaseBackend | undefined,
leaseProvider:
readStringParam(params, 'leaseProvider') ?? readStringParam(params, 'provider'),
deviceKey: readStringParam(params, 'deviceKey'),
clientId: readStringParam(params, 'clientId'),
},
};
}
+87
View File
@@ -7,6 +7,26 @@ export type LeaseScope = {
leaseId?: string;
leaseTtlMs?: number;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type SessionLease = {
tenantId: string;
runId: string;
leaseId: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type LeaseDiagnosticsContext = Omit<LeaseScope, 'leaseTtlMs'>;
type SessionLeaseSource = {
lease?: SessionLease | null;
deviceLease?: SessionLease | null;
};
export function resolveLeaseScope(req: Pick<DaemonRequest, 'flags' | 'meta'>): LeaseScope {
@@ -16,5 +36,72 @@ export function resolveLeaseScope(req: Pick<DaemonRequest, 'flags' | 'meta'>): L
leaseId: req.meta?.leaseId ?? req.flags?.leaseId,
leaseTtlMs: req.meta?.leaseTtlMs,
leaseBackend: req.meta?.leaseBackend,
leaseProvider:
req.meta?.leaseProvider ??
readFlagString(req.flags, 'leaseProvider') ??
readFlagString(req.flags, 'provider'),
deviceKey: req.meta?.deviceKey ?? readFlagString(req.flags, 'deviceKey'),
clientId: req.meta?.clientId ?? readFlagString(req.flags, 'clientId'),
};
}
export function buildSessionLeaseFromRequest(
req: Pick<DaemonRequest, 'flags' | 'meta'>,
): SessionLease | undefined {
const leaseScope = resolveLeaseScope(req);
if (!leaseScope.tenantId || !leaseScope.runId || !leaseScope.leaseId) {
return undefined;
}
return stripUndefined({
tenantId: leaseScope.tenantId,
runId: leaseScope.runId,
leaseId: leaseScope.leaseId,
leaseBackend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
});
}
export function resolveRequestOrSessionLeaseScope(
req: Pick<DaemonRequest, 'flags' | 'meta'>,
session?: SessionLeaseSource | null,
): LeaseScope {
const requestScope = resolveLeaseScope(req);
const sessionLease = session?.lease ?? session?.deviceLease ?? undefined;
return stripUndefined({
tenantId: requestScope.tenantId ?? sessionLease?.tenantId,
runId: requestScope.runId ?? sessionLease?.runId,
leaseId: requestScope.leaseId ?? sessionLease?.leaseId,
leaseTtlMs: requestScope.leaseTtlMs,
leaseBackend: requestScope.leaseBackend ?? sessionLease?.leaseBackend,
leaseProvider: requestScope.leaseProvider ?? sessionLease?.leaseProvider,
deviceKey: requestScope.deviceKey ?? sessionLease?.deviceKey,
clientId: requestScope.clientId ?? sessionLease?.clientId,
});
}
export function buildLeaseDiagnosticsContext(
leaseScope: LeaseScope | SessionLease | undefined,
): LeaseDiagnosticsContext | undefined {
if (!leaseScope) return undefined;
const context = stripUndefined({
tenantId: leaseScope.tenantId,
runId: leaseScope.runId,
leaseId: leaseScope.leaseId,
leaseBackend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
});
return Object.keys(context).length > 0 ? context : undefined;
}
function readFlagString(flags: object | undefined, key: string): string | undefined {
const value = (flags as Record<string, unknown> | undefined)?.[key];
return typeof value === 'string' ? value : undefined;
}
function stripUndefined<T extends Record<string, unknown>>(record: T): T {
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T;
}
+254 -41
View File
@@ -1,18 +1,24 @@
import crypto from 'node:crypto';
import type { LeaseBackend } from '../contracts.ts';
import { AppError } from '../utils/errors.ts';
import { normalizeTenantId } from './config.ts';
import type { LeaseBackend } from '../contracts.ts';
export type SimulatorLease = {
export type DeviceLease = {
leaseId: string;
tenantId: string;
runId: string;
backend: LeaseBackend;
leaseProvider?: string;
provider?: string;
deviceKey?: string;
clientId?: string;
createdAt: number;
heartbeatAt: number;
expiresAt: number;
};
export type SimulatorLease = DeviceLease;
export type LeaseRegistryOptions = {
maxActiveSimulatorLeases?: number;
defaultLeaseTtlMs?: number;
@@ -25,6 +31,10 @@ export type AllocateLeaseRequest = {
tenantId: string;
runId: string;
backend?: LeaseBackend;
provider?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
ttlMs?: number;
};
@@ -32,6 +42,11 @@ export type HeartbeatLeaseRequest = {
leaseId: string;
tenantId?: string;
runId?: string;
backend?: LeaseBackend;
provider?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
ttlMs?: number;
};
@@ -39,6 +54,11 @@ export type ReleaseLeaseRequest = {
leaseId: string;
tenantId?: string;
runId?: string;
backend?: LeaseBackend;
provider?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
export type AdmissionRequest = {
@@ -46,11 +66,16 @@ export type AdmissionRequest = {
runId: string | undefined;
leaseId: string | undefined;
backend?: LeaseBackend;
provider?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};
const DEFAULT_LEASE_TTL_MS = 60_000;
const MIN_LEASE_TTL_MS = 5_000;
const MAX_LEASE_TTL_MS = 10 * 60_000;
const DEFAULT_LEASE_PROVIDER = 'default';
function normalizeRunId(raw: string | undefined): string | undefined {
if (!raw) return undefined;
@@ -75,9 +100,51 @@ function normalizeLeaseBackend(raw: string | undefined): LeaseBackend {
throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`);
}
function normalizeDeviceKey(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const value = raw.trim();
if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) {
throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.');
}
return value;
}
function normalizeClientId(raw: string | undefined): string | undefined {
return normalizeAgentIdentifier(raw, 'client id', 128);
}
function normalizeLeaseProviderFields(request: {
provider?: string;
leaseProvider?: string;
}): string | undefined {
const provider = normalizeAgentIdentifier(request.provider, 'lease provider', 64);
const leaseProvider = normalizeAgentIdentifier(request.leaseProvider, 'lease provider', 64);
if (provider && leaseProvider && provider !== leaseProvider) {
throw new AppError('INVALID_ARGS', 'Conflicting lease provider values.');
}
return leaseProvider ?? provider;
}
function normalizeAgentIdentifier(
raw: string | undefined,
label: string,
maxLength: number,
): string | undefined {
if (raw === undefined) return undefined;
const value = raw.trim();
if (!value || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) {
throw new AppError(
'INVALID_ARGS',
`Invalid ${label}. Use 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen.`,
);
}
return value;
}
export class LeaseRegistry {
private readonly leases = new Map<string, SimulatorLease>();
private readonly leases = new Map<string, DeviceLease>();
private readonly runBindings = new Map<string, string>();
private readonly deviceBindings = new Map<string, string>();
private readonly maxActiveSimulatorLeases: number;
private readonly defaultLeaseTtlMs: number;
private readonly minLeaseTtlMs: number;
@@ -100,8 +167,11 @@ export class LeaseRegistry {
this.now = options.now ?? (() => Date.now());
}
allocateLease(request: AllocateLeaseRequest): SimulatorLease {
allocateLease(request: AllocateLeaseRequest): DeviceLease {
const backend = normalizeLeaseBackend(request.backend);
const provider = normalizeLeaseProviderFields(request);
const deviceKey = normalizeDeviceKey(request.deviceKey);
const clientId = normalizeClientId(request.clientId);
const tenantId = normalizeTenantId(request.tenantId);
if (!tenantId) {
throw new AppError(
@@ -118,32 +188,37 @@ export class LeaseRegistry {
}
this.cleanupExpiredLeases();
const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs);
const bindingKey = this.bindingKey(tenantId, runId, backend);
const bindingKey = this.bindingKey({ tenantId, runId, backend, provider, deviceKey });
const existingId = this.runBindings.get(bindingKey);
if (existingId) {
const existingLease = this.leases.get(existingId);
if (existingLease) {
this.assertOptionalLeaseIdentityMatch(existingLease, { clientId });
return this.refreshLease(existingLease, leaseTtlMs);
}
this.runBindings.delete(bindingKey);
}
this.assertDeviceAvailable({ backend, provider, deviceKey });
this.enforceCapacity(backend);
const now = this.now();
const lease: SimulatorLease = {
const lease: DeviceLease = {
leaseId: crypto.randomBytes(16).toString('hex'),
tenantId,
runId,
backend,
...(provider ? { leaseProvider: provider, provider } : {}),
...(deviceKey ? { deviceKey } : {}),
...(clientId ? { clientId } : {}),
createdAt: now,
heartbeatAt: now,
expiresAt: now + leaseTtlMs,
};
this.leases.set(lease.leaseId, lease);
this.runBindings.set(bindingKey, lease.leaseId);
this.bindLease(lease);
return { ...lease };
}
heartbeatLease(request: HeartbeatLeaseRequest): SimulatorLease {
heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease {
const leaseId = normalizeLeaseId(request.leaseId);
if (!leaseId) {
throw new AppError('INVALID_ARGS', 'Invalid lease id.');
@@ -155,7 +230,15 @@ export class LeaseRegistry {
reason: 'LEASE_NOT_FOUND',
});
}
this.assertOptionalScopeMatch(lease, request.tenantId, request.runId);
this.assertOptionalScopeMatch(lease, {
tenantId: request.tenantId,
runId: request.runId,
backend: request.backend,
provider: request.provider,
leaseProvider: request.leaseProvider,
deviceKey: request.deviceKey,
clientId: request.clientId,
});
const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs);
return this.refreshLease(lease, leaseTtlMs);
}
@@ -170,9 +253,17 @@ export class LeaseRegistry {
if (!lease) {
return { released: false };
}
this.assertOptionalScopeMatch(lease, request.tenantId, request.runId);
this.assertOptionalScopeMatch(lease, {
tenantId: request.tenantId,
runId: request.runId,
backend: request.backend,
provider: request.provider,
leaseProvider: request.leaseProvider,
deviceKey: request.deviceKey,
clientId: request.clientId,
});
this.leases.delete(leaseId);
this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend));
this.unbindLease(lease);
return { released: true };
}
@@ -197,14 +288,18 @@ export class LeaseRegistry {
reason: 'LEASE_NOT_FOUND',
});
}
if (lease.backend !== backend || lease.tenantId !== tenantId || lease.runId !== runId) {
throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', {
reason: 'LEASE_SCOPE_MISMATCH',
});
}
this.assertOptionalScopeMatch(lease, {
tenantId,
runId,
backend,
provider: request.provider,
leaseProvider: request.leaseProvider,
deviceKey: request.deviceKey,
clientId: request.clientId,
});
}
listActiveLeases(): SimulatorLease[] {
listActiveLeases(): DeviceLease[] {
this.cleanupExpiredLeases();
return Array.from(this.leases.values()).map((entry) => ({ ...entry }));
}
@@ -214,7 +309,7 @@ export class LeaseRegistry {
for (const lease of this.leases.values()) {
if (lease.expiresAt > now) continue;
this.leases.delete(lease.leaseId);
this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend));
this.unbindLease(lease);
}
}
@@ -246,53 +341,171 @@ export class LeaseRegistry {
return value;
}
private refreshLease(lease: SimulatorLease, ttlMs: number): SimulatorLease {
private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease {
const now = this.now();
const updated: SimulatorLease = {
const updated: DeviceLease = {
...lease,
heartbeatAt: now,
expiresAt: now + ttlMs,
};
this.leases.set(updated.leaseId, updated);
this.runBindings.set(
this.bindingKey(updated.tenantId, updated.runId, updated.backend),
updated.leaseId,
);
this.bindLease(updated);
return { ...updated };
}
private bindingKey(tenantId: string, runId: string, backend: LeaseBackend): string {
return `${tenantId}:${runId}:${backend}`;
private bindLease(lease: DeviceLease): void {
this.runBindings.set(
this.bindingKey({
tenantId: lease.tenantId,
runId: lease.runId,
backend: lease.backend,
provider: lease.leaseProvider,
deviceKey: lease.deviceKey,
}),
lease.leaseId,
);
const deviceBindingKey = this.deviceBindingKey(lease);
if (deviceBindingKey) {
this.deviceBindings.set(deviceBindingKey, lease.leaseId);
}
}
private unbindLease(lease: DeviceLease): void {
this.runBindings.delete(
this.bindingKey({
tenantId: lease.tenantId,
runId: lease.runId,
backend: lease.backend,
provider: lease.leaseProvider,
deviceKey: lease.deviceKey,
}),
);
const deviceBindingKey = this.deviceBindingKey(lease);
if (deviceBindingKey) {
this.deviceBindings.delete(deviceBindingKey);
}
}
private bindingKey(params: {
tenantId: string;
runId: string;
backend: LeaseBackend;
provider?: string;
deviceKey?: string;
}): string {
return JSON.stringify([
params.tenantId,
params.runId,
params.backend,
params.provider ?? DEFAULT_LEASE_PROVIDER,
params.deviceKey ?? '*',
]);
}
private deviceBindingKey(
lease: Pick<DeviceLease, 'backend' | 'leaseProvider' | 'deviceKey'>,
): string | undefined {
if (!lease.deviceKey) return undefined;
return JSON.stringify([
lease.backend,
lease.leaseProvider ?? DEFAULT_LEASE_PROVIDER,
lease.deviceKey,
]);
}
private assertDeviceAvailable(params: {
backend: LeaseBackend;
provider?: string;
deviceKey?: string;
}): void {
const deviceBindingKey = this.deviceBindingKey({
backend: params.backend,
leaseProvider: params.provider,
deviceKey: params.deviceKey,
});
if (!deviceBindingKey) return;
const activeLeaseId = this.deviceBindings.get(deviceBindingKey);
if (!activeLeaseId) return;
const activeLease = this.leases.get(activeLeaseId);
if (!activeLease) {
this.deviceBindings.delete(deviceBindingKey);
return;
}
throw new AppError('COMMAND_FAILED', 'Device is already leased', {
reason: 'DEVICE_LEASE_BUSY',
deviceKey: activeLease.deviceKey,
backend: activeLease.backend,
leaseProvider: activeLease.leaseProvider,
leaseId: activeLease.leaseId,
tenantId: activeLease.tenantId,
runId: activeLease.runId,
expiresAt: activeLease.expiresAt,
hint: 'Retry after the lease expires or close the owning session.',
});
}
private assertOptionalScopeMatch(
lease: SimulatorLease,
tenantRaw: string | undefined,
runRaw: string | undefined,
lease: DeviceLease,
request: {
tenantId?: string;
runId?: string;
backend?: LeaseBackend;
provider?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
},
): void {
const tenantId = normalizeTenantId(tenantRaw);
const runId = normalizeRunId(runRaw);
if (tenantRaw && !tenantId) {
const tenantId = normalizeTenantId(request.tenantId);
const runId = normalizeRunId(request.runId);
if (request.tenantId && !tenantId) {
throw new AppError(
'INVALID_ARGS',
'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.',
);
}
if (runRaw && !runId) {
if (request.runId && !runId) {
throw new AppError(
'INVALID_ARGS',
'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.',
);
}
if (tenantId && lease.tenantId !== tenantId) {
throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', {
reason: 'LEASE_SCOPE_MISMATCH',
});
const backend = request.backend ? normalizeLeaseBackend(request.backend) : undefined;
const provider = normalizeLeaseProviderFields(request);
const deviceKey = normalizeDeviceKey(request.deviceKey);
const clientId = normalizeClientId(request.clientId);
if (
(tenantId && lease.tenantId !== tenantId) ||
(runId && lease.runId !== runId) ||
(backend && lease.backend !== backend)
) {
this.throwScopeMismatch();
}
if (runId && lease.runId !== runId) {
throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', {
reason: 'LEASE_SCOPE_MISMATCH',
});
this.assertOptionalLeaseIdentityMatch(lease, { provider, deviceKey, clientId });
}
private assertOptionalLeaseIdentityMatch(
lease: DeviceLease,
request: {
provider?: string;
deviceKey?: string;
clientId?: string;
},
): void {
if (request.provider && lease.leaseProvider !== request.provider) {
this.throwScopeMismatch();
}
if (request.deviceKey && lease.deviceKey !== request.deviceKey) {
this.throwScopeMismatch();
}
if (request.clientId && lease.clientId !== request.clientId) {
this.throwScopeMismatch();
}
}
private throwScopeMismatch(): never {
throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', {
reason: 'LEASE_SCOPE_MISMATCH',
});
}
}
+3
View File
@@ -62,5 +62,8 @@ export function assertRequestLeaseAdmission(
runId: leaseScope.runId,
leaseId: leaseScope.leaseId,
backend: leaseScope.leaseBackend,
leaseProvider: leaseScope.leaseProvider,
deviceKey: leaseScope.deviceKey,
clientId: leaseScope.clientId,
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import {
REMOTE_CONFIG_FIELD_SPECS,
REMOTE_CONFIG_PROFILE_FIELD_SPECS,
getRemoteConfigEnvNames,
getRemoteConfigFieldSpec,
type RemoteConfigProfile,
@@ -74,7 +74,7 @@ function readRemoteConfigEnvDefaults(
env: Record<string, string | undefined> = process.env,
): RemoteConfigProfile {
const profile: RemoteConfigProfile = {};
for (const spec of REMOTE_CONFIG_FIELD_SPECS) {
for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) {
const envMatch = getRemoteConfigEnvNames(spec.key)
.map((name) => ({ name, value: env[name] }))
.find((entry) => typeof entry.value === 'string' && entry.value.trim().length > 0);
@@ -95,7 +95,7 @@ function mergeRemoteConfigProfile(
const merged: RemoteConfigProfile = {};
for (const profile of profiles) {
if (!profile) continue;
for (const spec of REMOTE_CONFIG_FIELD_SPECS) {
for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) {
const value = profile[spec.key];
if (value !== undefined) {
(merged as Record<string, unknown>)[spec.key] = value;
+15 -1
View File
@@ -35,6 +35,9 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & {
runId?: string;
leaseId?: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
platform?: PlatformSelector;
target?: DeviceTarget;
device?: string;
@@ -109,8 +112,19 @@ export const REMOTE_CONFIG_FIELD_SPECS = [
{ key: 'metroNoInstallDeps', type: 'boolean' },
] as const satisfies readonly RemoteConfigFieldSpec[];
const REMOTE_CONFIG_LEASE_FIELD_SPECS = [
{ key: 'leaseProvider', type: 'string', env: false },
{ key: 'deviceKey', type: 'string', env: false },
{ key: 'clientId', type: 'string', env: false },
] as const satisfies readonly RemoteConfigFieldSpec[];
export const REMOTE_CONFIG_PROFILE_FIELD_SPECS = [
...REMOTE_CONFIG_FIELD_SPECS,
...REMOTE_CONFIG_LEASE_FIELD_SPECS,
] as const satisfies readonly RemoteConfigFieldSpec[];
const remoteConfigFieldSpecByKey = new Map(
REMOTE_CONFIG_FIELD_SPECS.map((spec) => [spec.key, spec]),
REMOTE_CONFIG_PROFILE_FIELD_SPECS.map((spec) => [spec.key, spec]),
);
export function getRemoteConfigFieldSpec(
+6
View File
@@ -21,6 +21,9 @@ export type RemoteConnectionState = {
runId: string;
leaseId?: string;
leaseBackend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
platform?: CliFlags['platform'];
target?: CliFlags['target'];
runtime?: SessionRuntimeHints;
@@ -280,6 +283,9 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState
typeof record.runId === 'string' &&
(record.leaseId === undefined || typeof record.leaseId === 'string') &&
(record.leaseBackend === undefined || typeof record.leaseBackend === 'string') &&
(record.leaseProvider === undefined || typeof record.leaseProvider === 'string') &&
(record.deviceKey === undefined || typeof record.deviceKey === 'string') &&
(record.clientId === undefined || typeof record.clientId === 'string') &&
typeof record.connectedAt === 'string' &&
typeof record.updatedAt === 'string'
);