Skip to content
Closed
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: 11 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -95,6 +98,9 @@ export type AgentDeviceRequestOverrides = Pick<
| 'runId'
| 'leaseId'
| 'leaseBackend'
| 'leaseProvider'
| 'deviceKey'
| 'clientId'
| 'cwd'
| 'debug'
| 'iosXctestrunFile'
Expand Down Expand Up @@ -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;
Expand All @@ -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 = {
Expand Down
56 changes: 56 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -129,6 +132,9 @@ export type LeaseAllocatePayload = {
runId?: string;
ttlMs?: number;
backend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};

export type LeaseHeartbeatPayload = {
Expand All @@ -139,6 +145,10 @@ export type LeaseHeartbeatPayload = {
runId?: string;
leaseId?: string;
ttlMs?: number;
backend?: LeaseBackend;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
};

export type LeaseReleasePayload = {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -431,13 +479,19 @@ function parseLeaseScope(
tenantId?: string;
tenant?: string;
runId?: string;
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
} {
return {
token: optionalString(record, 'token', path),
session: optionalString(record, 'session', path),
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),
};
}

Expand All @@ -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),
};
});

Expand All @@ -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),
};
});

Expand Down
113 changes: 113 additions & 0 deletions src/daemon/__tests__/lease-context.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading