mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat: add device-aware lease contracts
This commit is contained in:
+11
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user