From 04f53bbc04a6e6379e9d845e39b336e6601fa860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 07:59:47 +0200 Subject: [PATCH 1/3] docs: clarify agent-device help entrypoint --- src/utils/__tests__/args.test.ts | 47 +++++++++++++++++++++++---- src/utils/cli-help.ts | 56 ++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..95349d3ce 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1177,10 +1177,35 @@ test('usage includes only global flags in the top-level global flags section', ( test('usage includes agent workflows, config, environment, and examples footers', () => { const usageText = usage(); + assert.match( + usageText, + /CLI to automate supported app, device, desktop, and web targets for AI agents/, + ); assert.ok( usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'), 'Agent workflows should appear before the command list for agents that only read the top of help.', ); + assert.ok( + usageText.indexOf('Agent Starting Point:') < usageText.indexOf('Agent Workflows:'), + 'The agent starting point should appear before topic selection.', + ); + assert.match(usageText, /Agent Starting Point:/); + assert.match( + usageText, + /agent-device is the default automation surface for app\/device workflows across supported targets/, + ); + assert.match( + usageText, + /Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network\/perf evidence, and verification/, + ); + assert.match( + usageText, + /Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step/, + ); + assert.match( + usageText, + /Start with agent-device help workflow to understand the core loop and how to use the tool/, + ); assert.match(usageText, /Agent Quickstart:/); assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/); assert.match(usageText, /Use selectors or refs as positional targets/); @@ -1225,21 +1250,30 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /Full operating guide: agent-device help workflow/); assert.match(usageText, /Exploratory QA: agent-device help dogfood/); assert.match(usageText, /Agent Workflows:/); - assert.match(usageText, /help workflow\s+Normal bootstrap, exploration, and validation loop/); - assert.match(usageText, /help debugging\s+Logs, network, perf memory, and traces/); assert.match( usageText, - /help react-devtools\s+React Native performance, profiling, component tree, and renders/, + /agent-device help workflow\s+Start here for the core loop, command shape, refs\/selectors, and verification/, + ); + assert.match( + usageText, + /agent-device help debugging\s+Use when logs, network, perf memory, traces, alerts, or diagnostics matter/, + ); + assert.match( + usageText, + /agent-device help react-devtools\s+Use when inspecting components, props\/state\/hooks, renders, or profiles/, + ); + assert.match( + usageText, + /agent-device help physical-device\s+Use when using a connected phone\/tablet or iOS signing setup/, ); assert.match( usageText, - /help physical-device\s+Connected phone\/tablet setup and iOS signing prerequisites/, + /agent-device help react-native\s+Use when the target app is React Native, Expo, or a dev client/, ); assert.match( usageText, - /help react-native\s+React Native app automation hazards, overlays, Metro, and routing/, + /agent-device help web\s+Use when automating a browser through agent-device sessions/, ); - assert.match(usageText, /help web\s+Minimal browser sessions through agent-browser/); assert.match(usageText, /Configuration:/); assert.match( usageText, @@ -1663,6 +1697,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /For app\/package launches, run metro prepare/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..7b5117d63 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -10,31 +10,50 @@ import { } from './command-schema.ts'; const AGENT_WORKFLOWS = [ - { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, perf memory, and traces' }, { - label: 'help react-native', - description: 'React Native app automation hazards, overlays, Metro, and routing', + label: 'agent-device help workflow', + description: 'Start here for the core loop, command shape, refs/selectors, and verification', }, { - label: 'help react-devtools', - description: 'React Native performance, profiling, component tree, and renders', + label: 'agent-device help debugging', + description: 'Use when logs, network, perf memory, traces, alerts, or diagnostics matter', }, { - label: 'help cdp', - description: 'React Native CDP targets, JS heap snapshots, and leak triage', + label: 'agent-device help react-native', + description: 'Use when the target app is React Native, Expo, or a dev client', }, { - label: 'help physical-device', - description: 'Connected phone/tablet setup and iOS signing prerequisites', + label: 'agent-device help react-devtools', + description: 'Use when inspecting components, props/state/hooks, renders, or profiles', }, { - label: 'help remote', - description: 'Remote/cloud config, tenants, leases, and local service tunnels', + label: 'agent-device help cdp', + description: 'Use when investigating JS heap growth, heap snapshots, or retainers', }, - { label: 'help web', description: 'Minimal browser sessions through agent-browser' }, - { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, - { label: 'help dogfood', description: 'Exploratory QA report workflow' }, + { + label: 'agent-device help physical-device', + description: 'Use when using a connected phone/tablet or iOS signing setup', + }, + { + label: 'agent-device help remote', + description: 'Use when working through cloud config, tenants, leases, or local tunnels', + }, + { + label: 'agent-device help web', + description: 'Use when automating a browser through agent-device sessions', + }, + { + label: 'agent-device help macos', + description: 'Use when targeting desktop, frontmost app, or menu bar surfaces', + }, + { label: 'agent-device help dogfood', description: 'Use when producing exploratory QA evidence' }, +] as const; + +const AGENT_START_LINES = [ + 'agent-device is the default automation surface for app/device workflows across supported targets.', + 'Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network/perf evidence, and verification.', + 'Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step.', + 'Start with agent-device help workflow to understand the core loop and how to use the tool.', ] as const; const AGENT_QUICKSTART_LINES = [ @@ -536,7 +555,7 @@ React Native dev loop: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach local Metro. Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. Multiple local worktrees can reuse one native iOS simulator build by running each worktree's Metro on a different port and opening the same installed app on different simulators with explicit runtime hints: @@ -863,7 +882,7 @@ function buildCommandListUsage(commandName: string, schema: CommandSchema): stri function renderUsageText(): string { const header = `agent-device [args] [--json] -CLI to control iOS and Android devices for AI agents. +CLI to automate supported app, device, desktop, and web targets for AI agents. `; const commands = listCliCommandNames().map((name) => { @@ -878,6 +897,7 @@ CLI to control iOS and Android devices for AI agents. const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); const flagsSection = renderFlagSection('Global Flags:', helpFlags); + const startSection = renderTextSection('Agent Starting Point:', AGENT_START_LINES); const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); @@ -885,6 +905,8 @@ CLI to control iOS and Android devices for AI agents. const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); return `${header} +${startSection} + ${workflowsSection} ${commandLines} From c593ecb04d1a8c835b9e096c8a71c3a1f73dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:10:03 +0200 Subject: [PATCH 2/3] docs: trim duplicated help guidance --- src/utils/cli-help.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 7b5117d63..3898d0e7a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -88,7 +88,6 @@ const AGENT_QUICKSTART_LINES = [ 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', 'Debug evidence: Session state contains request diagnostics and runner.log; use logs clear --restart/mark/path, trace, and network dump --include headers for app evidence.', - 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', ] as const; From 11dd8be6ae7c3ffe2612b5d1492cd11c1e376cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:51:53 +0200 Subject: [PATCH 3/3] feat: add device-aware lease contracts --- CONTEXT.md | 11 + docs/adr/0007-remote-device-leases.md | 47 +++ src/client-types.ts | 19 ++ src/contracts.ts | 56 ++++ src/daemon/__tests__/lease-context.test.ts | 113 +++++++ src/daemon/__tests__/lease-registry.test.ts | 145 +++++++++ .../__tests__/request-handler-catalog.test.ts | 62 ++++ src/daemon/handlers/lease.ts | 11 + src/daemon/http-server.ts | 4 + src/daemon/lease-context.ts | 87 ++++++ src/daemon/lease-registry.ts | 293 +++++++++++++++--- src/daemon/request-admission.ts | 3 + src/remote-config-core.ts | 6 +- src/remote-config-schema.ts | 16 +- src/remote-connection-state.ts | 6 + 15 files changed, 835 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0007-remote-device-leases.md create mode 100644 src/daemon/__tests__/lease-context.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 5295238f0..c363ecf6a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md new file mode 100644 index 000000000..91c78c301 --- /dev/null +++ b/docs/adr/0007-remote-device-leases.md @@ -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. + diff --git a/src/client-types.ts b/src/client-types.ts index 4885e47f6..76d331e89 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -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 = { diff --git a/src/contracts.ts b/src/contracts.ts index c2a3a514c..cef68f9a1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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; @@ -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, + 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, + 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, key: string, @@ -374,6 +419,9 @@ export const daemonCommandRequestSchema = schema((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((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((input, path) => { return { ...parseLeaseScope(record, path), leaseId: optionalString(record, 'leaseId', path), + backend: optionalEnum(record, 'backend', LEASE_BACKENDS, path), }; }); diff --git a/src/daemon/__tests__/lease-context.test.ts b/src/daemon/__tests__/lease-context.test.ts new file mode 100644 index 000000000..4f51b7b53 --- /dev/null +++ b/src/daemon/__tests__/lease-context.test.ts @@ -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, { + 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); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index f260a3a0e..d995e05db 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -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 }).details?.reason === 'DEVICE_LEASE_BUSY' && + (error as { details?: Record }).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 }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId, leaseProvider: 'limrun' }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => registry.heartbeatLease({ leaseId: lease.leaseId, clientId: 'client-b' }), + (error) => + error instanceof Error && + (error as { details?: Record }).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); +}); diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index db53cafef..e987c7eb5 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -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): 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 & { + 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).leaseId, 'string'); + return lease as Record & { leaseId: string }; +} diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index ad36e01ac..186ff2a9c 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -16,6 +16,9 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise; + +type SessionLeaseSource = { + lease?: SessionLease | null; + deviceLease?: SessionLease | null; }; export function resolveLeaseScope(req: Pick): LeaseScope { @@ -16,5 +36,72 @@ export function resolveLeaseScope(req: Pick): 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, +): 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, + 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 | undefined)?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3f5a4ba3f..0273db9d0 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -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(); + private readonly leases = new Map(); private readonly runBindings = new Map(); + private readonly deviceBindings = new Map(); 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.bindLease(updated); + return { ...updated }; + } + + private bindLease(lease: DeviceLease): void { this.runBindings.set( - this.bindingKey(updated.tenantId, updated.runId, updated.backend), - updated.leaseId, + this.bindingKey({ + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + provider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + lease.leaseId, ); - return { ...updated }; + 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 bindingKey(tenantId: string, runId: string, backend: LeaseBackend): string { - return `${tenantId}:${runId}:${backend}`; + private deviceBindingKey( + lease: Pick, + ): 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', + }); + } } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 1930a0784..032b18494 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -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, }); } diff --git a/src/remote-config-core.ts b/src/remote-config-core.ts index 69c5c8e38..c5f8e8011 100644 --- a/src/remote-config-core.ts +++ b/src/remote-config-core.ts @@ -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 = 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)[spec.key] = value; diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 6ca159a2a..e86d975cb 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -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( diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index e4354d152..7b0db8d10 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -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' );