diff --git a/CONTEXT.md b/CONTEXT.md index cf19f19e4..da6bf0bc6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,15 @@ - Modality: broad supported device family, such as mobile, tv, or desktop. - Session: daemon-owned state for a selected target and opened app or surface. - Recording backend: daemon-internal module interface selected per recording target that owns platform recording validation, output path policy, start/stop execution, and record-only cleanup below the daemon recording lifecycle. +- 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`. +- 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..6864093c9 --- /dev/null +++ b/docs/adr/0007-remote-device-leases.md @@ -0,0 +1,52 @@ +# ADR 0007: Remote Device Leases + +## Status + +Accepted + +## Context + +Remote daemon users need a clear ownership boundary before commands reach a +platform runner or helper. Shared proxy and hosted providers need ownership to +include the selected device and connection provider, not only tenant/run. + +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 client for a connection provider such as `proxy`, cloud, or `limrun`. + +`connect` establishes connection profile and client identity. Lease allocation +is lazy and happens 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. + +Lease admission, heartbeat, stored session lease refresh, and request execution +must run under the same daemon request lock. Scope resolution may happen before +the lock, but lease ownership mutation must not. + +Generated connection profiles are non-secret. They may persist routing and +lease metadata, but must strip daemon and Metro bearer tokens. Tokens are +supplied in-memory for the current command or through environment/CLI token +paths. + +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, while provider-aware +clients get device-level contention and clearer recovery. diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index fc4662452..92bb8fbbc 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -152,12 +152,16 @@ function mockCloudConnectionProfile(connection: Record): Return function assertGeneratedProfileState(state: RemoteConnectionState): void { assert.equal(state.tenant, 'acme'); assert.equal(state.runId, 'demo-run-001'); + assert.equal(state.leaseProvider, 'cloud'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); assert.match(state.remoteConfigPath, /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/); assert.equal(state.remoteConfigHash, hashRemoteConfigFile(state.remoteConfigPath)); assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'clientId', 'daemonBaseUrl', 'daemonTransport', + 'leaseProvider', 'metroKind', 'metroProxyBaseUrl', 'metroPublicBaseUrl', @@ -165,7 +169,10 @@ function assertGeneratedProfileState(state: RemoteConnectionState): void { 'sessionIsolation', 'tenant', ]); - assert.equal(readGeneratedConfig(state.remoteConfigPath).tenant, 'acme'); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.tenant, 'acme'); + assert.equal(generated.leaseProvider, 'cloud'); + assert.equal(generated.clientId, state.clientId); } function fetchProfileUrl(fetchMock: ReturnType): string | undefined { @@ -190,8 +197,16 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise } } -function readGeneratedConfig(configPath: string): { tenant?: string } { - return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string }; +function readGeneratedConfig(configPath: string): { + tenant?: string; + leaseProvider?: string; + clientId?: string; +} { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + tenant?: string; + leaseProvider?: string; + clientId?: string; + }; } function readGeneratedConfigKeys(configPath: string): string[] { diff --git a/src/__tests__/proxy-command.test.ts b/src/__tests__/proxy-command.test.ts new file mode 100644 index 000000000..ca9eca921 --- /dev/null +++ b/src/__tests__/proxy-command.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { renderProxyStartup } from '../cli/commands/proxy.ts'; +import { colorize } from '../utils/output.ts'; + +const STARTUP = { + proxyBaseUrl: 'http://127.0.0.1:4310', + agentDeviceBaseUrl: 'http://127.0.0.1:4310/agent-device', + token: 'proxy-secret', + upstreamBaseUrl: 'http://127.0.0.1:60149', + stateDir: '/private/tmp/agent-device-proxy', +}; + +test('renderProxyStartup keeps human output concise without color', () => { + const output = renderProxyStartup(STARTUP, { useColor: false }); + + assert.equal( + output, + [ + '✓ Proxy listening at http://127.0.0.1:4310', + '', + 'Provide this to the agent-device instance connecting:', + '', + 'Daemon base URL: ', + 'Daemon auth token: proxy-secret', + ].join('\n'), + ); + assert.doesNotMatch(output, /upstream local daemon/); + assert.doesNotMatch(output, /state dir/); + assert.doesNotMatch(output, /Remote client example/); + assert.doesNotMatch(output, /agent-device devices --daemon-base-url/); +}); + +test('renderProxyStartup colors status, urls, and token', () => { + const output = renderProxyStartup(STARTUP, { useColor: true }); + + assert.equal( + output, + [ + `${colored('✓', 'green')} Proxy listening at ${colored('http://127.0.0.1:4310', 'cyan')}`, + '', + 'Provide this to the agent-device instance connecting:', + '', + `Daemon base URL: ${colored('', 'cyan')}`, + `Daemon auth token: ${colored('proxy-secret', 'yellow')}`, + ].join('\n'), + ); +}); + +function colored(text: string, format: Parameters[1]): string { + return colorize(text, format, { validateStream: false }); +} diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index e0b5c4f19..e6d2233e8 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -17,9 +17,11 @@ import { connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; +import { writeGeneratedRemoteConfig } from '../cli/generated-remote-config.ts'; import { hasDeferredMetroConfig, materializeRemoteConnectionForCommand, + PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; @@ -74,11 +76,27 @@ function createTestClient( release?: AgentDeviceClient['leases']['release']; prepare?: AgentDeviceClient['metro']['prepare']; closeSession?: AgentDeviceClient['sessions']['close']; + listDevices?: AgentDeviceClient['devices']['list']; } = {}, ): AgentDeviceClient { return { command: createThrowingMethodGroup(), - devices: createThrowingMethodGroup(), + devices: createThrowingMethodGroup({ + list: + options.listDevices ?? + (async () => [ + { + platform: 'android', + target: 'mobile', + kind: 'emulator', + id: 'emulator-5554', + name: 'Android Emulator', + booted: true, + identifiers: { serial: 'emulator-5554' }, + android: { serial: 'emulator-5554' }, + }, + ]), + }), sessions: createThrowingMethodGroup({ close: options.closeSession ?? @@ -182,6 +200,209 @@ test('connect auto-generates a local session and writes minimal remote state', a fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect proxy writes normal remote state with generated non-secret profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-')); + const stateDir = path.join(tempRoot, '.state'); + + await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + daemonAuthToken: 'proxy-secret', + metroBearerToken: 'metro-bearer-secret', + platform: 'android', + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + assert.match(state.session, /^adc-[a-z0-9]+$/); + assert.equal(state.tenant, 'proxy'); + assert.match(state.runId, /^proxy-[a-f0-9]{16}$/); + assert.equal(state.leaseProvider, 'proxy'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); + assert.equal(state.leaseBackend, 'android-instance'); + assert.equal(state.leaseId, undefined); + assert.deepEqual(state.daemon, { + baseUrl: 'http://proxy.example.test/agent-device', + authToken: 'proxy-secret', + transport: 'http', + }); + assert.match(state.remoteConfigPath, /remote-connections\/generated\/proxy-[a-f0-9]{16}\.json$/); + const generated = JSON.parse(fs.readFileSync(state.remoteConfigPath, 'utf8')) as Record< + string, + unknown + >; + assert.equal(generated.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(generated.daemonAuthToken, undefined); + assert.equal(generated.metroBearerToken, undefined); + assert.equal(generated.leaseProvider, 'proxy'); + assert.equal(generated.leaseTtlMs, undefined); + assert.equal(JSON.stringify(generated).includes('proxy-secret'), false); + assert.equal(JSON.stringify(generated).includes('metro-bearer-secret'), false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect daemon-base-url shortcut uses proxy profile for direct proxy URLs', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-shortcut-')); + const stateDir = path.join(tempRoot, '.state'); + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://127.0.0.1:4310/agent-device', + daemonAuthToken: 'proxy-secret', + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + assert.equal(state.tenant, 'proxy'); + assert.equal(state.leaseProvider, 'proxy'); + assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/); + assert.deepEqual(state.daemon, { + baseUrl: 'http://127.0.0.1:4310/agent-device', + authToken: 'proxy-secret', + transport: 'http', + }); + assert.equal(state.leaseId, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy scopes generated client identity by explicit session', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-sessions-')); + const stateDir = path.join(tempRoot, '.state'); + + for (const session of ['agent-a', 'agent-b']) { + await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + platform: 'android', + session, + }, + client: createTestClient(), + }); + }); + } + + const first = readRemoteConnectionState({ stateDir, session: 'agent-a' }); + const second = readRemoteConnectionState({ stateDir, session: 'agent-b' }); + assert.ok(first); + assert.ok(second); + assert.notEqual(first.clientId, second.clientId); + assert.notEqual(first.runId, second.runId); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy notice only advertises open as the lease allocator', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-notice-')); + const stateDir = path.join(tempRoot, '.state'); + + const stdout = await captureStdout(async () => { + await connectCommand({ + positionals: ['proxy'], + flags: { + json: false, + help: false, + version: false, + stateDir, + daemonBaseUrl: 'http://proxy.example.test/agent-device', + platform: 'android', + }, + client: createTestClient(), + }); + }); + + assert.match(stdout, /Proxy lease allocation is pending/); + assert.match(stdout, /run open when ready/); + assert.doesNotMatch(stdout, /snapshot/); + assert.doesNotMatch(stdout, /install-from-source/); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('generated remote config writer strips secret fields', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-generated-profile-')); + const configPath = writeGeneratedRemoteConfig({ + stateDir: path.join(tempRoot, '.state'), + provider: 'proxy', + profile: { + daemonBaseUrl: 'http://proxy.example.test/agent-device', + daemonAuthToken: 'proxy-secret', + metroBearerToken: 'metro-bearer-secret', + leaseProvider: 'proxy', + clientId: 'client-a', + }, + }); + + const generated = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record; + assert.equal(generated.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(generated.daemonAuthToken, undefined); + assert.equal(generated.metroBearerToken, undefined); + assert.equal(generated.leaseProvider, 'proxy'); + assert.equal(JSON.stringify(generated).includes('proxy-secret'), false); + assert.equal(JSON.stringify(generated).includes('metro-bearer-secret'), false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect proxy rejects remote-config and unknown provider combinations', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-errors-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + + await assert.rejects( + async () => + await connectCommand({ + positionals: ['proxy'], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + }, + client: createTestClient(), + }), + /mutually exclusive/, + ); + + await assert.rejects( + async () => + await connectCommand({ + positionals: ['wat'], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: createTestClient(), + }), + /Supported providers: proxy/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect reports deferred Metro runtime preparation when remote config has Metro settings', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-metro-notice-')); const stateDir = path.join(tempRoot, '.state'); @@ -399,6 +620,135 @@ test('deferred materialization allocates lease and prepares Metro for open', asy fs.rmSync(tempRoot, { recursive: true, force: true }); }); +// fallow-ignore-next-line complexity +test('proxy open resolves device key before allocating lease', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-open-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let allocateRequest: Parameters[0] | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client: createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + ], + allocate: async (request) => { + allocateRequest = request; + return { + leaseId: 'abc123abc123abc1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'ios-instance', + leaseProvider: request.leaseProvider, + clientId: request.clientId, + deviceKey: request.deviceKey, + }; + }, + }), + }); + + assert.equal(allocateRequest?.leaseProvider, 'proxy'); + assert.equal(allocateRequest?.clientId, 'client-1'); + assert.equal(allocateRequest?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(allocateRequest?.ttlMs, PROXY_REMOTE_LEASE_TTL_MS); + assert.equal(allocateRequest?.leaseBackend, 'ios-instance'); + assert.equal(materialized.flags.leaseId, 'abc123abc123abc1'); + assert.equal(materialized.flags.udid, 'SIM-001'); + assert.equal(materialized.connection?.deviceKey, 'ios:mobile:SIM-001'); + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.leaseId, 'abc123abc123abc1'); + assert.equal(state?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(state?.leaseProvider, 'proxy'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('proxy commands without active device lease fail before allocation', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-proxy-closed-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + leaseBackend: 'ios-instance', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await assert.rejects( + async () => + await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client: createTestClient({ + allocate: async () => { + throw new Error('snapshot should not allocate without proxy device lease'); + }, + }), + }), + /No active proxy device lease for this session; run open first/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('direct remote-config materialization creates state and prepares Metro for open', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-direct-remote-open-')); const stateDir = path.join(tempRoot, '.state'); @@ -1084,6 +1434,7 @@ test('deferred materialization stops the new Metro companion if state persistenc const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + let releaseRequest: Parameters[0] | undefined; writeRemoteConnectionState({ stateDir, state: { @@ -1136,7 +1487,12 @@ test('deferred materialization stops the new Metro companion if state persistenc metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', }, - client: createTestClient(), + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; + }, + }), }), writeFailure, ); @@ -1147,6 +1503,10 @@ test('deferred materialization stops the new Metro companion if state persistenc profileKey: remoteConfigPath, consumerKey: 'adc-android', }); + assert.equal(releaseRequest?.leaseId, 'lease-1'); + assert.equal(releaseRequest?.tenant, 'acme'); + assert.equal(releaseRequest?.runId, 'run-123'); + assert.equal(releaseRequest?.leaseBackend, 'android-instance'); fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -1486,6 +1846,65 @@ test('disconnect without a session uses active connection state', async () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('disconnect releases proxy lease with provider client and device metadata', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-proxy-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-proxy', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'proxy', + runId: 'proxy-client-1', + leaseId: 'abc123abc123abc1', + daemon: { + baseUrl: 'http://proxy.example.test/agent-device', + authToken: 'proxy-secret', + }, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-001', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let releaseRequest: Parameters[0] | undefined; + + await captureStdout(async () => { + await disconnectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + shutdown: true, + }, + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; + }, + }), + }); + }); + + assert.equal(releaseRequest?.leaseProvider, 'proxy'); + assert.equal(releaseRequest?.clientId, 'client-1'); + assert.equal(releaseRequest?.deviceKey, 'ios:mobile:SIM-001'); + assert.equal(releaseRequest?.leaseId, 'abc123abc123abc1'); + assert.equal(releaseRequest?.leaseBackend, 'ios-instance'); + assert.equal(releaseRequest?.daemonBaseUrl, 'http://proxy.example.test/agent-device'); + assert.equal(releaseRequest?.daemonAuthToken, 'proxy-secret'); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' }), null); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connection status reports missing state without daemon calls', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connection-status-')); let handled = false; diff --git a/src/cli.ts b/src/cli.ts index ffe71a550..89505716a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,10 @@ import { resolveDaemonPaths } from './daemon/config.ts'; import { applyDefaultPlatformBinding, resolveBindingSettings } from './utils/session-binding.ts'; import { resolveCliOptions } from './utils/cli-options.ts'; import { maybeRunUpgradeNotifier } from './utils/update-check.ts'; -import { resolveRemoteConnectionDefaults } from './remote-connection-state.ts'; +import { + resolveRemoteConnectionDefaults, + type RemoteConnectionRequestMetadata, +} from './remote-connection-state.ts'; import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { CliFlags, FlagKey } from './utils/cli-flags.ts'; import type { SessionRuntimeHints } from './contracts.ts'; @@ -223,9 +226,11 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): flags: effectiveFlags, }); let resolvedRuntime = connectionDefaults?.runtime; + let connectionMetadata = connectionDefaults?.connection; const buildClientConfig = ( currentFlags: CliFlags, runtime: SessionRuntimeHints | undefined, + connection: RemoteConnectionRequestMetadata | undefined, ): AgentDeviceClientConfig => ({ session: currentFlags.session, requestId, @@ -239,6 +244,9 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): runId: currentFlags.runId, leaseId: currentFlags.leaseId, leaseBackend: currentFlags.leaseBackend, + leaseProvider: connection?.leaseProvider, + clientId: connection?.clientId, + deviceKey: connection?.deviceKey, runtime, lockPolicy: binding.lockPolicy, lockPlatform: binding.defaultPlatform, @@ -265,7 +273,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): if (effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(command)) { const materializationClient = createAgentDeviceClient( - buildClientConfig(effectiveFlags, resolvedRuntime), + buildClientConfig(effectiveFlags, resolvedRuntime, connectionMetadata), { transport: deps.sendToDaemon as AgentDeviceDaemonTransport, }, @@ -281,6 +289,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }); effectiveFlags = materialized.flags; resolvedRuntime = materialized.runtime; + connectionMetadata = materialized.connection; } if ( shouldWarnOpenMayMissRemoteRuntime({ @@ -310,13 +319,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): debugOutputEnabled && !effectiveFlags.json && !remoteDaemonBaseUrl ? startDaemonLogTail(daemonPaths.logPath) : null; - const client = createAgentDeviceClient(buildClientConfig(effectiveFlags, resolvedRuntime), { - transport: createCliDaemonTransport({ - command, - flags: effectiveFlags, - transport: deps.sendToDaemon as AgentDeviceDaemonTransport, - }), - }); + const client = createAgentDeviceClient( + buildClientConfig(effectiveFlags, resolvedRuntime, connectionMetadata), + { + transport: createCliDaemonTransport({ + command, + flags: effectiveFlags, + transport: deps.sendToDaemon as AgentDeviceDaemonTransport, + }), + }, + ); if (command === 'batch') { if (!parsedBatchSteps) { throw new AppError('INVALID_ARGS', 'batch requires --steps or --steps-file.'); @@ -459,6 +471,7 @@ function resolveActiveConnectionDefaults(options: { }): { flags: Partial; runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; } | null { if ( options.command === 'connect' || diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 5914daa16..1e183674e 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -1,14 +1,11 @@ import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { resolveRemoteConfigProfile } from '../remote-config.ts'; -import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; -import { profileToCliFlags } from '../utils/remote-config.ts'; -import { AppError, asAppError } from '../utils/errors.ts'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { AppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; import { resolveCloudAccessForConnect } from './auth-session.ts'; import { readCloudJsonResponse } from './cloud-response.ts'; +import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; const HTTP_TIMEOUT_MS = 15_000; @@ -40,24 +37,28 @@ export async function resolveCloudConnectProfile(options: { accessToken: auth.accessToken, fetchImpl: options.fetchImpl, }); - const remoteConfigPath = writeGeneratedRemoteConfig({ + const clientId = buildCloudClientId({ stateDir: options.stateDir, - profile, + cloudBaseUrl: auth.cloudBaseUrl, + daemonBaseUrl: typeof profile.daemonBaseUrl === 'string' ? profile.daemonBaseUrl : '', + session: options.flags.session, }); - const remoteConfig = resolveGeneratedRemoteConfigProfile({ - configPath: remoteConfigPath, + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: 'cloud', + profile: { + ...profile, + leaseProvider: profile.leaseProvider ?? 'cloud', + clientId: profile.clientId ?? clientId, + runId: profile.runId ?? `cloud-${clientId}`, + }, cwd: options.cwd, env: options.env, - }); - return { - flags: { - ...profileToCliFlags(remoteConfig.profile), - ...options.flags, - remoteConfig: remoteConfig.resolvedPath, + flags: options.flags, + extraFlags: { daemonAuthToken: auth.accessToken, }, - remoteConfigPath: remoteConfig.resolvedPath, - }; + }); } async function fetchConnectionProfile(options: { @@ -108,60 +109,17 @@ function parseRemoteConfigProfile(value: unknown): RemoteConfigProfile { return value as RemoteConfigProfile; } -function resolveGeneratedRemoteConfigProfile(options: { - configPath: string; - cwd: string; - env?: EnvMap; -}): ResolvedRemoteConfigProfile { - try { - // Re-read the generated file to reuse the standard env merge, type coercion, and path resolution. - return resolveRemoteConfigProfile(options); - } catch (error) { - const appError = asAppError(error); - throw new AppError( - 'COMMAND_FAILED', - 'Cloud connection profile returned invalid remote config.', - { - generatedConfigPath: options.configPath, - cause: appError.message, - }, - appError, - ); - } -} - -function writeGeneratedRemoteConfig(options: { +function buildCloudClientId(options: { stateDir: string; - profile: RemoteConfigProfile; + cloudBaseUrl: string; + daemonBaseUrl: string; + session: string | undefined; }): string { - const normalized = normalizeJson(options.profile); - const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); - fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const configPath = path.join(configDir, `cloud-${profileHash(normalized)}.json`); - fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); - try { - fs.chmodSync(configPath, 0o600); - } catch { - // Best effort on filesystems that do not support POSIX mode bits. - } - return configPath; -} - -function profileHash(value: unknown): string { - return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); -} - -function normalizeJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalizeJson); - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => [key, normalizeJson(entryValue)]), - ); - } - return value; + return crypto + .createHash('sha256') + .update( + `${options.stateDir}\0${options.cloudBaseUrl}\0${options.daemonBaseUrl}\0${options.session ?? ''}`, + ) + .digest('hex') + .slice(0, 16); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index cc52e429b..31c5092dd 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -2,14 +2,17 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopReactDevtoolsCompanion } from '../../client-react-devtools-companion.ts'; import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; +import { resolveDevice, type DeviceInfo } from '../../utils/device.ts'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import type { MetroBridgeScope } from '../../client-companion-tunnel-contract.ts'; import { buildRemoteConnectionDaemonState, + buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, readRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, + type RemoteConnectionRequestMetadata, } from '../../remote-connection-state.ts'; import { profileToCliFlags } from '../../utils/remote-config.ts'; import type { BatchStep } from '../../client-types.ts'; @@ -28,6 +31,7 @@ const leaseDeferredCommands = new Set([ 'session', ]); const runtimeDeferredCommands = new Set(['open']); +export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export async function materializeRemoteConnectionForCommand(options: { command: string; @@ -37,7 +41,11 @@ export async function materializeRemoteConnectionForCommand(options: { positionals?: string[]; batchSteps?: BatchStep[]; forceRuntimePrepare?: boolean; -}): Promise<{ flags: CliFlags; runtime?: SessionRuntimeHints }> { +}): Promise<{ + flags: CliFlags; + runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; +}> { const { command, flags, client } = options; if (!flags.remoteConfig) { return { flags, runtime: options.runtime }; @@ -72,91 +80,56 @@ export async function materializeRemoteConnectionForCommand(options: { } const state = - existingState ?? createRemoteConnectionStateFromFlags(mergedFlags, remoteConfig.resolvedPath); + existingState ?? + createRemoteConnectionStateFromFlags( + mergedFlags, + remoteConfig.resolvedPath, + remoteConfig.profile, + ); const nextFlags = { ...mergedFlags, session: state.session }; let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; let changed = !existingState; - let metroCleanupToStop: RemoteConnectionState['metro'] | undefined; - let preparedMetroCleanupOnFailure: RemoteConnectionState['metro'] | undefined; - - if (shouldAllocateLeaseForCommand(command)) { - const leaseBackend = state.leaseBackend ?? requireRequestedLeaseBackend(flags, command); - assertRequestedConnectionScope(state, flags, leaseBackend); - const lease = await allocateOrReuseLease(client, nextState, leaseBackend); - nextFlags.leaseId = lease.leaseId; - nextFlags.leaseBackend = leaseBackend; - nextFlags.platform = nextState.platform ?? nextFlags.platform; - nextFlags.target = nextState.target ?? nextFlags.target; - if (nextState.leaseId !== lease.leaseId || nextState.leaseBackend !== leaseBackend) { - nextState = { - ...nextState, - leaseId: lease.leaseId, - leaseBackend, - platform: nextState.platform ?? flags.platform, - target: nextState.target ?? flags.target, - updatedAt: new Date().toISOString(), - }; - changed = true; - } - } + let acquiredLeaseForCleanup: Lease | undefined; - if ( - shouldPrepareRuntimeForCommand(command, nextFlags, options.batchSteps, options.positionals) && - hasDeferredMetroConfig(nextFlags) - ) { - if (!nextState.leaseId && nextFlags.leaseId) { - nextState = { - ...nextState, - leaseId: nextFlags.leaseId, - leaseBackend: nextFlags.leaseBackend, - }; - } - const requiresPreparedRuntime = - options.forceRuntimePrepare || - !nextRuntime || - !isRuntimeCompatibleWithPlatform(nextRuntime, nextFlags.platform); - if (requiresPreparedRuntime) { - if (!nextState.leaseId) { - throw new AppError( - 'INVALID_ARGS', - `${command} requires a resolved remote lease before Metro runtime can be prepared.`, - ); - } - const prepared = await prepareConnectedMetro( - nextFlags, - client, - state.remoteConfigPath, - state.session, - { - tenantId: state.tenant, - runId: state.runId, - leaseId: nextState.leaseId, - }, - ); - nextRuntime = prepared.runtime; - const replacesExistingMetroCleanup = !isSameMetroCleanup(nextState.metro, prepared.cleanup); - metroCleanupToStop = replacesExistingMetroCleanup ? nextState.metro : undefined; - preparedMetroCleanupOnFailure = replacesExistingMetroCleanup ? prepared.cleanup : undefined; - nextState = { - ...nextState, - runtime: prepared.runtime, - metro: prepared.cleanup, - updatedAt: new Date().toISOString(), - }; - changed = true; - } + const leasePolicy = connectionLeasePolicyForState(nextState); + if (leasePolicy.shouldAllocate(command)) { + const materializedLease = await materializeLeaseForCommand({ + command, + client, + state, + nextState, + nextFlags, + policy: leasePolicy, + }); + nextState = materializedLease.state; + changed = changed || materializedLease.changed; + acquiredLeaseForCleanup = materializedLease.acquiredLeaseForCleanup; } - if (changed) { - try { - writeRemoteConnectionState({ stateDir, state: nextState }); - } catch (error) { - await stopMetroCleanup(preparedMetroCleanupOnFailure); - throw error; - } - } - await stopMetroCleanup(metroCleanupToStop); + const runtimePreparation = await prepareRuntimeForCommand({ + command, + flags: nextFlags, + client, + state, + nextState, + runtime: nextRuntime, + positionals: options.positionals, + batchSteps: options.batchSteps, + forceRuntimePrepare: options.forceRuntimePrepare, + }); + nextState = runtimePreparation.state; + nextRuntime = runtimePreparation.runtime; + changed = changed || runtimePreparation.changed; + await persistMaterializedConnection({ + changed, + stateDir, + state: nextState, + client, + acquiredLeaseForCleanup, + preparedMetroCleanupOnFailure: runtimePreparation.preparedMetroCleanupOnFailure, + metroCleanupToStop: runtimePreparation.metroCleanupToStop, + }); return { flags: { @@ -168,9 +141,229 @@ export async function materializeRemoteConnectionForCommand(options: { target: nextState.target ?? nextFlags.target, }, runtime: nextRuntime, + connection: buildRemoteConnectionRequestMetadata(nextState), + }; +} + +async function prepareRuntimeForCommand(options: { + command: string; + flags: CliFlags; + client: AgentDeviceClient; + state: RemoteConnectionState; + nextState: RemoteConnectionState; + runtime?: SessionRuntimeHints; + positionals?: string[]; + batchSteps?: BatchStep[]; + forceRuntimePrepare?: boolean; +}): Promise<{ + state: RemoteConnectionState; + runtime?: SessionRuntimeHints; + changed: boolean; + metroCleanupToStop?: RemoteConnectionState['metro']; + preparedMetroCleanupOnFailure?: RemoteConnectionState['metro']; +}> { + const { command, flags, state, client } = options; + let nextState = ensureRuntimeLeaseState(options.nextState, flags); + const nextRuntime = options.runtime; + if ( + !shouldPrepareRuntimeForCommand(command, flags, options.batchSteps, options.positionals) || + !hasDeferredMetroConfig(flags) || + !shouldPrepareRuntime(options.forceRuntimePrepare, nextRuntime, flags.platform) + ) { + return { state: nextState, runtime: nextRuntime, changed: false }; + } + if (!nextState.leaseId) { + throw new AppError( + 'INVALID_ARGS', + `${command} requires a resolved remote lease before Metro runtime can be prepared.`, + ); + } + const prepared = await prepareConnectedMetro( + flags, + client, + state.remoteConfigPath, + state.session, + { + tenantId: state.tenant, + runId: state.runId, + leaseId: nextState.leaseId, + }, + ); + const replacesExistingMetroCleanup = !isSameMetroCleanup(nextState.metro, prepared.cleanup); + nextState = { + ...nextState, + runtime: prepared.runtime, + metro: prepared.cleanup, + updatedAt: new Date().toISOString(), + }; + return { + state: nextState, + runtime: prepared.runtime, + changed: true, + metroCleanupToStop: replacesExistingMetroCleanup ? options.nextState.metro : undefined, + preparedMetroCleanupOnFailure: replacesExistingMetroCleanup ? prepared.cleanup : undefined, + }; +} + +function ensureRuntimeLeaseState( + state: RemoteConnectionState, + flags: CliFlags, +): RemoteConnectionState { + if (state.leaseId || !flags.leaseId) return state; + return { + ...state, + leaseId: flags.leaseId, + leaseBackend: flags.leaseBackend, + }; +} + +function shouldPrepareRuntime( + forceRuntimePrepare: boolean | undefined, + runtime: SessionRuntimeHints | undefined, + platform: CliFlags['platform'], +): boolean { + return ( + forceRuntimePrepare === true || !runtime || !isRuntimeCompatibleWithPlatform(runtime, platform) + ); +} + +async function persistMaterializedConnection(options: { + changed: boolean; + stateDir: string; + state: RemoteConnectionState; + client: AgentDeviceClient; + acquiredLeaseForCleanup?: Lease; + preparedMetroCleanupOnFailure?: RemoteConnectionState['metro']; + metroCleanupToStop?: RemoteConnectionState['metro']; +}): Promise { + if (options.changed) { + try { + writeRemoteConnectionState({ stateDir: options.stateDir, state: options.state }); + } catch (error) { + await stopMetroCleanup(options.preparedMetroCleanupOnFailure); + await releaseAcquiredLeaseOnWriteFailure( + options.client, + options.state, + options.acquiredLeaseForCleanup, + ); + throw error; + } + } + await stopMetroCleanup(options.metroCleanupToStop); +} + +async function materializeLeaseForCommand(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + nextState: RemoteConnectionState; + nextFlags: CliFlags; + policy: ConnectionLeasePolicy; +}): Promise<{ + state: RemoteConnectionState; + changed: boolean; + acquiredLeaseForCleanup?: Lease; +}> { + const { command, client, state, nextFlags, policy } = options; + const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); + let nextState = options.nextState; + const resolvedLeaseState = await policy.resolveLeaseState({ + command, + client, + state: nextState, + flags: nextFlags, + leaseBackend: preliminaryLeaseBackend, + }); + nextState = resolvedLeaseState.state; + if (resolvedLeaseState.device) { + applyResolvedDeviceSelector(nextFlags, resolvedLeaseState.device); + } + const leaseBackend = + nextState.leaseBackend ?? + preliminaryLeaseBackend ?? + requireRequestedLeaseBackend(nextFlags, command); + assertRequestedConnectionScope(state, nextFlags, leaseBackend); + const materializedLease = await allocateOrReuseLease(client, nextState, leaseBackend, policy); + const lease = materializedLease.lease; + nextFlags.leaseId = lease.leaseId; + nextFlags.leaseBackend = leaseBackend; + nextFlags.platform = nextState.platform ?? nextFlags.platform; + nextFlags.target = nextState.target ?? nextFlags.target; + if (leaseStateMatches(nextState, lease, leaseBackend)) { + return { + state: nextState, + changed: false, + acquiredLeaseForCleanup: materializedLease.acquired ? lease : undefined, + }; + } + return { + state: buildMaterializedLeaseState(nextState, lease, leaseBackend, nextFlags), + changed: true, + acquiredLeaseForCleanup: materializedLease.acquired ? lease : undefined, }; } +function leaseStateMatches( + state: RemoteConnectionState, + lease: Lease, + leaseBackend: LeaseBackend, +): boolean { + return ( + state.leaseId === lease.leaseId && + state.leaseBackend === leaseBackend && + state.deviceKey === (lease.deviceKey ?? state.deviceKey) + ); +} + +function buildMaterializedLeaseState( + state: RemoteConnectionState, + lease: Lease, + leaseBackend: LeaseBackend, + flags: CliFlags, +): RemoteConnectionState { + return { + ...state, + leaseId: lease.leaseId, + leaseBackend, + leaseProvider: lease.leaseProvider ?? state.leaseProvider, + clientId: lease.clientId ?? state.clientId, + deviceKey: lease.deviceKey ?? state.deviceKey, + platform: state.platform ?? flags.platform, + target: state.target ?? flags.target, + updatedAt: new Date().toISOString(), + }; +} + +type ConnectionLeasePolicy = { + shouldAllocate(command: string): boolean; + ttlMs(state: RemoteConnectionState): number | undefined; + resolveLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; + }): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }>; +}; + +function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { + return state.leaseProvider === 'proxy' + ? PROXY_CONNECTION_LEASE_POLICY + : DEFAULT_CONNECTION_LEASE_POLICY; +} + +const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => !leaseDeferredCommands.has(command), + ttlMs: () => undefined, + resolveLeaseState: async (options) => ({ state: options.state }), +}; + +const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), + ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS, + resolveLeaseState: resolveProxyLeaseState, +}; + async function prepareConnectedMetro( flags: CliFlags, client: AgentDeviceClient, @@ -254,22 +447,57 @@ export async function stopReactDevtoolsCleanup(options: { } } +export async function releaseRemoteConnectionLease( + client: AgentDeviceClient, + state: RemoteConnectionState, +): Promise { + if (!state.leaseId) return false; + const result = await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: state.leaseId, + leaseBackend: state.leaseBackend, + daemonBaseUrl: state.daemon?.baseUrl, + daemonAuthToken: state.daemon?.authToken, + daemonTransport: state.daemon?.transport, + daemonServerMode: state.daemon?.serverMode, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + }); + return result.released; +} + export async function releasePreviousLease( client: AgentDeviceClient, previous: RemoteConnectionState, ): Promise { if (!previous.leaseId) return; + try { + await releaseRemoteConnectionLease(client, previous); + } catch { + // Reconnect must succeed even if the old lease was already released. + } +} + +async function releaseAcquiredLeaseOnWriteFailure( + client: AgentDeviceClient, + state: RemoteConnectionState, + lease: Lease | undefined, +): Promise { + if (!lease) return; try { await client.leases.release({ - tenant: previous.tenant, - runId: previous.runId, - leaseId: previous.leaseId, - daemonBaseUrl: previous.daemon?.baseUrl, - daemonTransport: previous.daemon?.transport, - daemonServerMode: previous.daemon?.serverMode, + tenant: state.tenant, + runId: state.runId, + leaseId: lease.leaseId, + leaseBackend: state.leaseBackend ?? lease.backend, + leaseProvider: state.leaseProvider ?? lease.leaseProvider, + clientId: state.clientId ?? lease.clientId, + deviceKey: state.deviceKey ?? lease.deviceKey, }); } catch { - // Reconnect must succeed even if the old lease was already released. + // Preserve the state-write failure; cleanup is best-effort. } } @@ -289,10 +517,6 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa ); } -function shouldAllocateLeaseForCommand(command: string): boolean { - return !leaseDeferredCommands.has(command); -} - function shouldPrepareRuntimeForCommand( command: string, flags: CliFlags, @@ -359,6 +583,7 @@ function selectCompatibleRuntime( function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, + profile: Pick = {}, ): RemoteConnectionState { if (!flags.tenant) { throw new AppError( @@ -389,6 +614,9 @@ function createRemoteConnectionStateFromFlags( runId: flags.runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), + leaseProvider: profile.leaseProvider, + clientId: profile.clientId, + deviceKey: profile.deviceKey, platform: flags.platform, target: flags.target, connectedAt: now, @@ -400,20 +628,114 @@ async function allocateOrReuseLease( client: AgentDeviceClient, state: RemoteConnectionState, leaseBackend: LeaseBackend, -): Promise { + policy: ConnectionLeasePolicy, +): Promise<{ lease: Lease; acquired: boolean }> { if (state.leaseId && state.leaseBackend === leaseBackend) { const existing = await heartbeatOrAllocateLease(client, state.leaseId, { tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: policy.ttlMs(state), }); - if (existing) return existing; + if (existing) return { lease: existing, acquired: false }; } - return await client.leases.allocate({ + const lease = await client.leases.allocate({ tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: policy.ttlMs(state), + }); + return { lease, acquired: true }; +} + +async function resolveProxyLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; +}): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }> { + if (options.command !== 'open') { + if (options.state.leaseId && options.state.deviceKey) return { state: options.state }; + throw new AppError( + 'INVALID_ARGS', + 'No active proxy device lease for this session; run open first.', + ); + } + const device = await resolveSelectedDevice(options.client, options.flags); + const deviceKey = buildProxyDeviceKey(device); + return { + state: { + ...options.state, + deviceKey, + leaseBackend: + options.state.leaseBackend ?? options.leaseBackend ?? leaseBackendForDevice(device), + platform: options.state.platform ?? device.platform, + target: options.state.target ?? device.target, + updatedAt: new Date().toISOString(), + }, + device, + }; +} + +function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { + flags.platform = device.platform; + flags.target = device.target ?? flags.target; + if (device.platform === 'ios') { + flags.udid = device.id; + return; + } + if (device.platform === 'android') { + flags.serial = device.id; + } +} + +async function resolveSelectedDevice( + client: AgentDeviceClient, + flags: CliFlags, +): Promise { + const devices = await client.devices.list({ + platform: flags.platform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, }); + return await resolveDevice( + devices.map((device) => ({ + platform: device.platform, + id: device.id, + name: device.name, + kind: device.kind, + target: device.target, + booted: device.booted, + })), + { + platform: flags.platform, + target: flags.target, + deviceName: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + ); +} + +function buildProxyDeviceKey(device: DeviceInfo): string { + return `${device.platform}:${device.target ?? 'mobile'}:${device.id}`; +} + +function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { + if (device.platform === 'ios') return 'ios-instance'; + if (device.platform === 'android') return 'android-instance'; + return undefined; } function assertRequestedConnectionScope( @@ -447,7 +769,15 @@ function assertRequestedConnectionScope( async function heartbeatOrAllocateLease( client: AgentDeviceClient, leaseId: string, - scope: { tenant: string; runId: string; leaseBackend: LeaseBackend }, + scope: { + tenant: string; + runId: string; + leaseBackend: LeaseBackend; + leaseProvider?: RemoteConnectionState['leaseProvider']; + clientId?: string; + deviceKey?: string; + ttlMs?: number; + }, ): Promise { try { return await client.leases.heartbeat({ @@ -455,6 +785,10 @@ async function heartbeatOrAllocateLease( runId: scope.runId, leaseId, leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + clientId: scope.clientId, + deviceKey: scope.deviceKey, + ttlMs: scope.ttlMs, }); } catch (error) { if (isInactiveLeaseError(error)) return undefined; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index cae7a593d..42d012251 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -10,11 +10,14 @@ import { removeRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, + type RemoteConnectionRequestMetadata, } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; import { resolveCloudConnectProfile } from '../cloud-connection-profile.ts'; +import { resolveProxyConnectProfile } from '../proxy-connection-profile.ts'; import { hasDeferredMetroConfig, + releaseRemoteConnectionLease, releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, @@ -25,110 +28,224 @@ import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { +export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const resolved = flags.remoteConfig - ? resolveRemoteConnectFlags(flags) - : await resolveCloudConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }); + const provider = readConnectProvider(positionals); + assertConnectProviderUsage(provider, flags); + const resolved = await resolveConnectProfile({ provider, flags, stateDir }); const connectFlags = resolved.flags; - const tenant = connectFlags.tenant; - const runId = connectFlags.runId; - if (!tenant) { + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); + const scope = readRequiredConnectScope(connectFlags); + const context = resolveConnectContext({ + stateDir, + flags: connectFlags, + remoteConfigPath: resolved.remoteConfigPath, + }); + assertCompatibleConnectionOrForce(context.previous, { + flags: connectFlags, + session: context.session, + remoteConfigPath: resolved.remoteConfigPath, + remoteConfigHash: context.remoteConfigHash, + desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), + connection: connectionMetadata, + daemon: context.daemon, + }); + const state = buildConnectedState({ + flags: connectFlags, + scope, + connectionMetadata, + context, + remoteConfigPath: resolved.remoteConfigPath, + }); + writeRemoteConnectionState({ stateDir, state }); + await cleanupForcedPreviousConnection(client, stateDir, connectFlags, context.previous); + const leasePreparation = buildLeasePreparationNotice(state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); + + writeCommandOutput(connectFlags, serializeConnectionState(state, runtimePreparation), () => + [ + `Connected remote session "${context.session}" tenant "${scope.tenant}" run "${scope.runId}" ${ + state.leaseId ? `lease ${state.leaseId}` : 'lease pending' + }`, + leasePreparation?.message, + runtimePreparation?.message, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'), + ); + return true; +}; + +async function resolveConnectProfile(options: { + provider?: 'proxy'; + flags: CliFlags; + stateDir: string; +}): Promise<{ flags: CliFlags; remoteConfigPath: string }> { + const { provider, flags, stateDir } = options; + if (flags.remoteConfig) return resolveRemoteConnectFlags(flags); + if (provider === 'proxy' || shouldUseProxyConnectShortcut(flags)) { + return resolveProxyConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + } + return await resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); +} + +function assertConnectProviderUsage(provider: 'proxy' | undefined, flags: CliFlags): void { + if (!provider || !flags.remoteConfig) return; + throw new AppError( + 'INVALID_ARGS', + 'connect provider positional and --remote-config are mutually exclusive.', + ); +} + +function readRequiredConnectScope(flags: CliFlags): { tenant: string; runId: string } { + if (!flags.tenant) { throw new AppError( 'INVALID_ARGS', 'connect requires tenant in remote config or via --tenant .', ); } - if (!runId) { + if (!flags.runId) { throw new AppError( 'INVALID_ARGS', 'connect requires runId in remote config or via --run-id .', ); } - if (!connectFlags.daemonBaseUrl) { + if (!flags.daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', 'connect requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', ); } + return { tenant: flags.tenant, runId: flags.runId }; +} - const activeState = connectFlags.session ? null : readActiveConnectionState({ stateDir }); - const session = connectFlags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); - const remoteConfigHash = hashRemoteConfigFile(resolved.remoteConfigPath); - const daemon = buildDaemonState(connectFlags); +type ConnectContext = { + session: string; + remoteConfigHash: string; + daemon: RemoteConnectionState['daemon']; + previous: RemoteConnectionState | null; +}; + +function resolveConnectContext(options: { + stateDir: string; + flags: CliFlags; + remoteConfigPath: string; +}): ConnectContext { + const { stateDir, flags, remoteConfigPath } = options; + const activeState = flags.session ? null : readActiveConnectionState({ stateDir }); + const session = flags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); const previous = activeState?.session === session ? activeState : readRemoteConnectionState({ stateDir, session }); - if ( - previous && - !isCompatibleConnection(previous, { - flags: connectFlags, - session, - remoteConfigPath: resolved.remoteConfigPath, - remoteConfigHash, - desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), - daemon, - }) - ) { - if (!connectFlags.force) { - throw new AppError( - 'INVALID_ARGS', - 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', - { session, remoteConfig: previous.remoteConfigPath }, - ); - } - } + return { + session, + previous, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: buildDaemonState(flags), + }; +} +function assertCompatibleConnectionOrForce( + previous: RemoteConnectionState | null, + options: Parameters[1], +): void { + if (!previous || isCompatibleConnection(previous, options)) return; + if (options.flags.force) return; + throw new AppError( + 'INVALID_ARGS', + 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', + { session: options.session, remoteConfig: previous.remoteConfigPath }, + ); +} + +function buildConnectedState(options: { + flags: CliFlags; + scope: { tenant: string; runId: string }; + connectionMetadata?: RemoteConnectionRequestMetadata; + context: ConnectContext; + remoteConfigPath: string; +}): RemoteConnectionState { + const { flags, scope, connectionMetadata, context, remoteConfigPath } = options; + const previous = shouldReusePreviousConnectionState(flags, context.previous) + ? context.previous + : null; const now = new Date().toISOString(); - const state: RemoteConnectionState = { + const leaseBinding = buildConnectionLeaseBinding(flags, previous, connectionMetadata); + const runtimeBinding = buildConnectionRuntimeBinding(flags, previous, now); + return { version: 1, - session, - remoteConfigPath: resolved.remoteConfigPath, - remoteConfigHash, - daemon, - tenant, - runId, - leaseId: previous && !connectFlags.force ? previous.leaseId : undefined, - leaseBackend: - previous && !connectFlags.force - ? previous.leaseBackend - : resolveRequestedLeaseBackend(connectFlags), - platform: - connectFlags.platform ?? (previous && !connectFlags.force ? previous.platform : undefined), - target: connectFlags.target ?? (previous && !connectFlags.force ? previous.target : undefined), - runtime: previous && !connectFlags.force ? previous.runtime : undefined, - metro: previous && !connectFlags.force ? previous.metro : undefined, - connectedAt: previous && !connectFlags.force ? previous.connectedAt : now, + session: context.session, + remoteConfigPath, + remoteConfigHash: context.remoteConfigHash, + daemon: context.daemon, + tenant: scope.tenant, + runId: scope.runId, + ...leaseBinding, + ...runtimeBinding, updatedAt: now, }; - writeRemoteConnectionState({ stateDir, state }); - if (previous && connectFlags.force) { - await stopMetroCleanup(previous.metro); - await stopReactDevtoolsCleanup({ stateDir, state: previous }); - await releasePreviousLease(client, previous); - } - const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); +} - writeCommandOutput(connectFlags, serializeConnectionState(state, runtimePreparation), () => - [ - `Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${ - state.leaseId ? `lease ${state.leaseId}` : 'lease pending' - }`, - leasePreparation?.message, - runtimePreparation?.message, - ] - .filter((line): line is string => Boolean(line)) - .join('\n'), - ); - return true; -}; +function buildConnectionLeaseBinding( + flags: CliFlags, + previous: RemoteConnectionState | null, + connectionMetadata: RemoteConnectionRequestMetadata | undefined, +): Pick< + RemoteConnectionState, + 'clientId' | 'deviceKey' | 'leaseBackend' | 'leaseId' | 'leaseProvider' +> { + return { + leaseId: previous?.leaseId, + leaseBackend: previous?.leaseBackend ?? resolveRequestedLeaseBackend(flags), + leaseProvider: connectionMetadata?.leaseProvider ?? previous?.leaseProvider, + clientId: connectionMetadata?.clientId ?? previous?.clientId, + deviceKey: previous?.deviceKey ?? connectionMetadata?.deviceKey, + }; +} + +function buildConnectionRuntimeBinding( + flags: CliFlags, + previous: RemoteConnectionState | null, + now: string, +): Pick { + return { + platform: flags.platform ?? previous?.platform, + target: flags.target ?? previous?.target, + runtime: previous?.runtime, + metro: previous?.metro, + connectedAt: previous?.connectedAt ?? now, + }; +} + +function shouldReusePreviousConnectionState( + flags: CliFlags, + previous: RemoteConnectionState | null, +): previous is RemoteConnectionState { + return Boolean(previous && !flags.force); +} + +async function cleanupForcedPreviousConnection( + client: Parameters[0]['client'], + stateDir: string, + flags: CliFlags, + previous: RemoteConnectionState | null, +): Promise { + if (!previous || !flags.force) return; + await stopMetroCleanup(previous.metro); + await stopReactDevtoolsCleanup({ stateDir, state: previous }); + await releasePreviousLease(client, previous); +} function resolveRemoteConnectFlags(flags: CliFlags): { flags: CliFlags; @@ -148,6 +265,22 @@ function resolveRemoteConnectFlags(flags: CliFlags): { }; } +function readRemoteConfigConnectionMetadata( + remoteConfigPath: string, +): RemoteConnectionRequestMetadata | undefined { + const profile = resolveRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: process.cwd(), + env: process.env, + }).profile; + const metadata = { + leaseProvider: profile.leaseProvider, + clientId: profile.clientId, + deviceKey: profile.deviceKey, + }; + return Object.values(metadata).some((value) => value !== undefined) ? metadata : undefined; +} + export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const { session, stateDir, state } = readRequestedConnectionState(flags); if (!state) { @@ -166,12 +299,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let released = false; if (state.leaseId) { try { - const result = await client.leases.release({ - tenant: state.tenant, - runId: state.runId, - leaseId: state.leaseId, - }); - released = result.released; + released = await releaseRemoteConnectionLease(client, state); } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } @@ -221,6 +349,35 @@ function createRemoteSessionName(stateDir: string): string { return `adc-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; } +function readConnectProvider(positionals: string[]): 'proxy' | undefined { + const provider = positionals[0]; + if (provider === undefined) return undefined; + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'connect accepts at most one provider positional.'); + } + if (provider === 'proxy') return provider; + throw new AppError( + 'INVALID_ARGS', + `Unknown connect provider: ${provider}. Supported providers: proxy.`, + ); +} + +function shouldUseProxyConnectShortcut(flags: CliFlags): boolean { + if (!flags.daemonBaseUrl || flags.tenant || flags.runId || flags.leaseId || flags.leaseBackend) { + return false; + } + return isAgentDeviceProxyBaseUrl(flags.daemonBaseUrl); +} + +function isAgentDeviceProxyBaseUrl(value: string): boolean { + try { + const url = new URL(value); + return url.pathname.replace(/\/+$/, '').endsWith('/agent-device'); + } catch { + return false; + } +} + function readRequestedConnectionState(flags: CliFlags): { session: string; stateDir: string; @@ -253,31 +410,49 @@ function isCompatibleConnection( remoteConfigPath: string; remoteConfigHash: string; desiredLeaseBackend?: LeaseBackend; + connection?: RemoteConnectionRequestMetadata; daemon: RemoteConnectionState['daemon']; }, ): boolean { return ( - state.remoteConfigPath === options.remoteConfigPath && - state.remoteConfigHash === options.remoteConfigHash && - state.session === options.session && - state.tenant === options.flags.tenant && - state.runId === options.flags.runId && - (options.desiredLeaseBackend === undefined || - state.leaseBackend === options.desiredLeaseBackend) && - (options.flags.platform === undefined || state.platform === options.flags.platform) && - (options.flags.target === undefined || state.target === options.flags.target) && + requiredConnectionFieldsMatch(state, options) && + optionalConnectionFieldsMatch(state, options) && isSameDaemonState(state.daemon, options.daemon) ); } +function requiredConnectionFieldsMatch( + state: RemoteConnectionState, + options: Parameters[1], +): boolean { + return [ + [state.remoteConfigPath, options.remoteConfigPath], + [state.remoteConfigHash, options.remoteConfigHash], + [state.session, options.session], + [state.tenant, options.flags.tenant], + [state.runId, options.flags.runId], + ].every(([left, right]) => left === right); +} + +function optionalConnectionFieldsMatch( + state: RemoteConnectionState, + options: Parameters[1], +): boolean { + return [ + [state.leaseBackend, options.desiredLeaseBackend], + [state.platform, options.flags.platform], + [state.target, options.flags.target], + [state.leaseProvider, options.connection?.leaseProvider], + [state.clientId, options.connection?.clientId], + ].every(([left, right]) => right === undefined || left === right); +} + function isSameDaemonState( a: RemoteConnectionState['daemon'], b: RemoteConnectionState['daemon'], ): boolean { - return ( - (a?.baseUrl ?? undefined) === (b?.baseUrl ?? undefined) && - (a?.transport ?? undefined) === (b?.transport ?? undefined) && - (a?.serverMode ?? undefined) === (b?.serverMode ?? undefined) + return (['baseUrl', 'transport', 'serverMode'] as const).every( + (key) => (a?.[key] ?? undefined) === (b?.[key] ?? undefined), ); } @@ -319,6 +494,14 @@ function buildLeasePreparationNotice( state: RemoteConnectionState, ): LeasePreparationNotice | undefined { if (state.leaseId) return undefined; + if (state.leaseProvider === 'proxy') { + return { + status: 'deferred', + nextSteps: ['agent-device open --relaunch', 'agent-device devices'], + message: + 'Proxy lease allocation is pending; run open when ready to allocate or refresh the device lease. Devices can inspect inventory but do not allocate a proxy lease.', + }; + } const needsPlatform = state.platform === undefined && state.leaseBackend === undefined ? ' Add --platform ios|android if the profile does not set a platform.' diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index f6baf5bf8..2188fd711 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -3,6 +3,7 @@ import { createDaemonProxyServer } from '../../daemon-proxy.ts'; import { buildDaemonHttpBaseUrl } from '../../daemon/http-contract.ts'; import { ensureDaemon, resolveClientSettings } from '../../daemon-client-lifecycle.ts'; import { AppError } from '../../utils/errors.ts'; +import { colorize, supportsColor } from '../../utils/output.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -89,20 +90,33 @@ function formatHostForUrl(host: string): string { return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; } -function renderProxyStartup(startup: ProxyStartup): string { +export function renderProxyStartup( + startup: ProxyStartup, + options: { useColor?: boolean } = {}, +): string { + const useColor = options.useColor ?? supportsColor(); + const checkmark = formatProxyOutputValue('✓', 'green', useColor); + const proxyBaseUrl = formatProxyOutputValue(startup.proxyBaseUrl, 'cyan', useColor); + const daemonBaseUrl = formatProxyOutputValue('', 'cyan', useColor); + const token = formatProxyOutputValue(startup.token, 'yellow', useColor); return [ - `agent-device proxy listening on ${startup.proxyBaseUrl}`, - `daemon base URL: ${startup.agentDeviceBaseUrl}`, - `daemon auth token: ${startup.token}`, - 'treat the daemon auth token as a secret; anyone with it can control the proxied daemon', - `upstream local daemon: ${startup.upstreamBaseUrl}`, - `state dir: ${startup.stateDir}`, + `${checkmark} Proxy listening at ${proxyBaseUrl}`, '', - 'Remote client example:', - `agent-device devices --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${startup.token}`, + 'Provide this to the agent-device instance connecting:', + '', + `Daemon base URL: ${daemonBaseUrl}`, + `Daemon auth token: ${token}`, ].join('\n'); } +function formatProxyOutputValue( + value: string, + format: Parameters[1], + useColor: boolean, +): string { + return useColor ? colorize(value, format, { validateStream: false }) : value; +} + function waitForever(): Promise { return new Promise(() => {}); } diff --git a/src/cli/generated-remote-config.ts b/src/cli/generated-remote-config.ts new file mode 100644 index 000000000..975289367 --- /dev/null +++ b/src/cli/generated-remote-config.ts @@ -0,0 +1,120 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveRemoteConfigProfile } from '../remote-config.ts'; +import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; +import { AppError, asAppError } from '../utils/errors.ts'; +import type { EnvMap } from '../utils/env-map.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import { profileToCliFlags } from '../utils/remote-config.ts'; + +const GENERATED_REMOTE_CONFIG_SECRET_KEYS = new Set(['daemonAuthToken', 'metroBearerToken']); + +export function writeGeneratedRemoteConfig(options: { + stateDir: string; + provider: string; + profile: RemoteConfigProfile; +}): string { + const normalized = normalizeJson(stripGeneratedProfileSecrets(options.profile)); + const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const configPath = path.join( + configDir, + `${safeProviderName(options.provider)}-${profileHash(normalized)}.json`, + ); + fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(configPath, 0o600); + } catch { + // Best effort on filesystems that do not support POSIX mode bits. + } + return configPath; +} + +function resolveGeneratedRemoteConfigProfile(options: { + configPath: string; + cwd: string; + env?: EnvMap; + provider: string; +}): ResolvedRemoteConfigProfile { + try { + // Re-read the generated file to reuse the standard env merge, type coercion, and path resolution. + return resolveRemoteConfigProfile(options); + } catch (error) { + const appError = asAppError(error); + throw new AppError( + 'COMMAND_FAILED', + `${options.provider} connection profile returned invalid remote config.`, + { + generatedConfigPath: options.configPath, + cause: appError.message, + }, + appError, + ); + } +} + +export function persistAndResolveGeneratedProfile(options: { + stateDir: string; + cwd: string; + env?: EnvMap; + provider: string; + profile: RemoteConfigProfile; + flags: CliFlags; + extraFlags?: Partial; +}): { flags: CliFlags; remoteConfigPath: string } { + const remoteConfigPath = writeGeneratedRemoteConfig({ + stateDir: options.stateDir, + provider: options.provider, + profile: options.profile, + }); + const remoteConfig = resolveGeneratedRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: options.cwd, + env: options.env, + provider: titleCaseProvider(options.provider), + }); + return { + flags: { + ...profileToCliFlags(remoteConfig.profile), + ...options.flags, + ...(options.extraFlags ?? {}), + remoteConfig: remoteConfig.resolvedPath, + }, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + +function stripGeneratedProfileSecrets(profile: RemoteConfigProfile): RemoteConfigProfile { + return Object.fromEntries( + Object.entries(profile).filter(([key]) => !GENERATED_REMOTE_CONFIG_SECRET_KEYS.has(key)), + ) as RemoteConfigProfile; +} + +function profileHash(value: unknown): string { + return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); +} + +function normalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeJson); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, normalizeJson(entryValue)]), + ); + } + return value; +} + +function safeProviderName(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'generated'; +} + +function titleCaseProvider(value: string): string { + const [first = '', ...rest] = value; + return `${first.toUpperCase()}${rest.join('')}`; +} diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts new file mode 100644 index 000000000..fc6d70316 --- /dev/null +++ b/src/cli/proxy-connection-profile.ts @@ -0,0 +1,82 @@ +import crypto from 'node:crypto'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { AppError } from '../utils/errors.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import type { EnvMap } from '../utils/env-map.ts'; +import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; +import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; + +export function resolveProxyConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env?.AGENT_DEVICE_DAEMON_BASE_URL; + if (!daemonBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'connect proxy requires --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL.', + ); + } + const clientId = buildProxyClientId(options.stateDir, daemonBaseUrl, options.flags.session); + const profile: RemoteConfigProfile = { + daemonBaseUrl, + daemonTransport: options.flags.daemonTransport ?? 'http', + daemonServerMode: options.flags.daemonServerMode, + tenant: options.flags.tenant ?? 'proxy', + sessionIsolation: options.flags.sessionIsolation ?? 'tenant', + runId: options.flags.runId ?? `proxy-${clientId}`, + leaseProvider: 'proxy', + clientId, + leaseBackend: options.flags.leaseBackend ?? resolveRequestedLeaseBackend(options.flags), + platform: options.flags.platform, + target: options.flags.target, + device: options.flags.device, + udid: options.flags.udid, + serial: options.flags.serial, + iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: options.flags.androidDeviceAllowlist, + session: options.flags.session, + metroProjectRoot: options.flags.metroProjectRoot, + metroKind: options.flags.metroKind, + metroPublicBaseUrl: options.flags.metroPublicBaseUrl, + metroProxyBaseUrl: options.flags.metroProxyBaseUrl, + // Secrets must never be persisted in the generated (non-secret) profile. + // Mirror the cloud path, which keeps daemonAuthToken in-memory only: the + // bearer token survives this connect via the returned flags below, and + // later commands re-supply it through AGENT_DEVICE_METRO_BEARER_TOKEN. + metroPreparePort: options.flags.metroPreparePort, + metroListenHost: options.flags.metroListenHost, + metroStatusHost: options.flags.metroStatusHost, + metroStartupTimeoutMs: options.flags.metroStartupTimeoutMs, + metroProbeTimeoutMs: options.flags.metroProbeTimeoutMs, + metroRuntimeFile: options.flags.metroRuntimeFile, + metroNoReuseExisting: options.flags.metroNoReuseExisting, + metroNoInstallDeps: options.flags.metroNoInstallDeps, + }; + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: 'proxy', + profile, + cwd: options.cwd, + env: options.env, + flags: options.flags, + extraFlags: { + daemonBaseUrl, + daemonTransport: options.flags.daemonTransport ?? 'http', + }, + }); +} + +function buildProxyClientId( + stateDir: string, + daemonBaseUrl: string, + session: string | undefined, +): string { + return crypto + .createHash('sha256') + .update(`${stateDir}\0${daemonBaseUrl}\0${session ?? ''}`) + .digest('hex') + .slice(0, 16); +} diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 213fa90ab..e98f1361d 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -4,6 +4,11 @@ import type { DaemonRequest, SessionRuntimeHints } from './daemon/types.ts'; import { AppError, type NormalizedError } from './utils/errors.ts'; import type { SnapshotNode } from './utils/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; +import { + leaseScopeFromOptions, + leaseScopeToCommandFlags, + leaseScopeToRequestMeta, +} from './core/lease-scope.ts'; import type { AgentDeviceDevice, AgentDeviceSession, @@ -266,17 +271,15 @@ export function readSnapshotNodes(value: unknown): SnapshotNode[] { } export function buildFlags(options: InternalRequestOptions): CommandFlags { + const leaseScope = leaseScopeFromOptions(options); return stripUndefined({ stateDir: options.stateDir, daemonBaseUrl: options.daemonBaseUrl, daemonAuthToken: options.daemonAuthToken, daemonTransport: options.daemonTransport, daemonServerMode: options.daemonServerMode, - tenant: options.tenant, + ...leaseScopeToCommandFlags(leaseScope), sessionIsolation: options.sessionIsolation, - runId: options.runId, - leaseId: options.leaseId, - leaseBackend: options.leaseBackend, platform: options.platform, target: options.target, device: options.device, @@ -350,6 +353,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { } export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'] { + const leaseScope = leaseScopeFromOptions(options); return stripUndefined({ requestId: options.requestId, cwd: options.cwd, @@ -357,11 +361,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' debug: options.debug, lockPolicy: options.lockPolicy, lockPlatform: options.lockPlatform, - tenantId: options.tenant, - runId: options.runId, - leaseId: options.leaseId, - leaseBackend: options.leaseBackend, - leaseTtlMs: options.leaseTtlMs, + ...leaseScopeToRequestMeta(leaseScope), sessionIsolation: options.sessionIsolation, installSource: options.installSource, retainMaterializedPaths: options.retainMaterializedPaths, diff --git a/src/client-types.ts b/src/client-types.ts index f414478f4..ef62703eb 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -6,8 +6,6 @@ import type { DaemonLockPolicy, DaemonRequest, DaemonResponse, - DaemonServerMode, - DaemonTransportPreference, LeaseBackend, NetworkIncludeMode, SessionIsolationMode, @@ -45,6 +43,7 @@ export type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; +import type { RemoteConnectionProfileFields } from './remote-config-schema.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; @@ -56,21 +55,14 @@ export type AgentDeviceDaemonTransport = ( req: Omit, ) => Promise; -export type AgentDeviceClientConfig = { +export type AgentDeviceClientConfig = RemoteConnectionProfileFields & { session?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; requestId?: string; - stateDir?: string; - daemonBaseUrl?: string; - daemonAuthToken?: string; - daemonTransport?: DaemonTransportPreference; - daemonServerMode?: DaemonServerMode; - tenant?: string; sessionIsolation?: SessionIsolationMode; - runId?: string; - leaseId?: string; leaseBackend?: LeaseBackend; + leaseTtlMs?: number; runtime?: SessionRuntimeHints; cwd?: string; debug?: boolean; @@ -94,6 +86,10 @@ export type AgentDeviceRequestOverrides = Pick< | 'runId' | 'leaseId' | 'leaseBackend' + | 'leaseProvider' + | 'deviceKey' + | 'clientId' + | 'leaseTtlMs' | 'cwd' | 'debug' | 'iosXctestrunFile' @@ -273,6 +269,9 @@ export type Lease = { tenantId: string; runId: string; backend: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; createdAt?: number; heartbeatAt?: number; expiresAt?: number; @@ -286,12 +285,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/client.ts b/src/client.ts index 6b6b95826..9f5d6d6c3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -215,16 +215,10 @@ export function createAgentDeviceClient( await execute(INTERNAL_COMMANDS.leaseAllocate, [], { ...options, leaseId: undefined, - leaseTtlMs: options.ttlMs, }), ), heartbeat: async (options) => - normalizeLease( - await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], { - ...options, - leaseTtlMs: options.ttlMs, - }), - ), + normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)), release: async (options) => { const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options); return { released: data.released === true }; @@ -388,6 +382,9 @@ function normalizeLease(data: Record): Lease { tenantId: readRequiredString(rawLease, 'tenantId'), runId: readRequiredString(rawLease, 'runId'), backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + leaseProvider: readOptionalString(rawLease, 'leaseProvider'), + clientId: readOptionalString(rawLease, 'clientId'), + deviceKey: readOptionalString(rawLease, 'deviceKey'), createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, diff --git a/src/commands/management/viewport.ts b/src/commands/management/viewport.ts index b121ca035..71c0fa080 100644 --- a/src/commands/management/viewport.ts +++ b/src/commands/management/viewport.ts @@ -1,7 +1,7 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { ViewportCommandOptions } from '../../client-types.ts'; +import { readViewportDimension } from '../../core/viewport-dimension.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { AppError } from '../../utils/errors.ts'; import { integerField, requiredField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; @@ -51,11 +51,3 @@ export const viewportCommandFacet = defineCommandFacet({ daemonWriter: viewportDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.viewport, }); - -function readViewportDimension(value: string | undefined, label: 'width' | 'height'): number { - const parsed = value === undefined ? NaN : Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); - } - return parsed; -} 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/core/__tests__/lease-scope.test.ts b/src/core/__tests__/lease-scope.test.ts new file mode 100644 index 000000000..3bdff94cd --- /dev/null +++ b/src/core/__tests__/lease-scope.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + findMissingProxyLeaseFields, + leaseScopeFromOptions, + leaseScopeFromRequest, + leaseScopeToCommandFlags, + leaseScopeToConnectionMetadata, + leaseScopeToLeaseRpcParams, + leaseScopeToRequestMeta, +} from '../lease-scope.ts'; + +test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { + const scope = leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + ttlMs: 120_000, + leaseBackend: 'ios-instance', + provider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + + assert.deepEqual(scope, { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 120_000, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + assert.deepEqual(leaseScopeToRequestMeta(scope), { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 120_000, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + assert.deepEqual(leaseScopeToCommandFlags(scope), { + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + }); +}); + +test('leaseScopeFromRequest prefers metadata and falls back to legacy flags', () => { + assert.deepEqual( + leaseScopeFromRequest({ + meta: { + tenantId: 'tenant-meta', + leaseProvider: 'limrun', + }, + flags: { + tenant: 'tenant-flag', + runId: 'run-flag', + leaseId: 'lease-flag', + provider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + }), + { + tenantId: 'tenant-meta', + runId: 'run-flag', + leaseId: 'lease-flag', + leaseProvider: 'limrun', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + ); +}); + +test('leaseScopeToLeaseRpcParams projects canonical provider and command-specific fields', () => { + const scope = leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseTtlMs: 60_000, + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + deviceKey: 'android:emulator-5554', + clientId: 'client-a', + }); + + assert.deepEqual( + leaseScopeToLeaseRpcParams(scope, 'lease_allocate', { + includeTokenParam: true, + token: 'token', + session: 'default', + }), + { + token: 'token', + session: 'default', + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'android:emulator-5554', + ttlMs: 60_000, + backend: 'android-instance', + }, + ); + assert.deepEqual( + leaseScopeToLeaseRpcParams(scope, 'lease_release', { + includeTokenParam: false, + token: 'token', + session: 'default', + }), + { + session: 'default', + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'android:emulator-5554', + leaseId: 'lease-1', + }, + ); +}); + +test('leaseScopeToConnectionMetadata returns only connection lease fields', () => { + assert.deepEqual( + leaseScopeToConnectionMetadata( + leaseScopeFromOptions({ + tenant: 'tenant-a', + runId: 'run-1', + leaseId: 'lease-1', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }), + ), + { + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + ); + assert.equal(leaseScopeToConnectionMetadata({}), undefined); +}); + +test('findMissingProxyLeaseFields enforces complete proxy ownership scope', () => { + assert.deepEqual( + findMissingProxyLeaseFields({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + }), + ['leaseId', 'deviceKey'], + ); + assert.deepEqual( + findMissingProxyLeaseFields({ + leaseProvider: 'limrun', + }), + [], + ); +}); diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 1077543e9..7c829e28a 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -6,6 +6,7 @@ import type { ClickButton } from './click-button.ts'; import type { ElementSelectorKey } from './interactor-types.ts'; import type { SwipePattern } from './scroll-gesture.ts'; import type { SessionSurface } from './session-surface.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; export type MaestroRuntimeFlags = { allowNonHittableCoordinateFallback?: boolean; @@ -43,6 +44,7 @@ export type DispatchContext = ScreenshotDispatchFlags & { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; snapshotInteractiveOnly?: boolean; snapshotDepth?: number; snapshotScope?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 154b6ce4a..88b80c060 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -34,6 +34,7 @@ import { } from './dispatch-interactions.ts'; import { readNotificationPayload } from './dispatch-payload.ts'; import { parseDeviceRotation } from './device-rotation.ts'; +import { readViewportDimension } from './viewport-dimension.ts'; export { resolveTargetDevice } from './dispatch-resolve.ts'; export type { CommandFlags, DispatchContext } from './dispatch-context.ts'; @@ -54,6 +55,7 @@ export async function dispatchCommand( iosXctestrunFile: context?.iosXctestrunFile, iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, iosXctestEnvDir: context?.iosXctestEnvDir, + runnerLeaseContext: context?.runnerLeaseContext, }; const interactor = await getInteractor(device, runnerCtx); emitDiagnostic({ @@ -327,14 +329,6 @@ async function handleClipboardCommand( }; } -function readViewportDimension(value: string | undefined, label: 'width' | 'height'): number { - const parsed = value === undefined ? NaN : Number(value); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); - } - return parsed; -} - async function handleKeyboardCommand( device: DeviceInfo, positionals: string[], diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index f357be18f..d26e0cdfa 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -4,6 +4,7 @@ import type { ScrollDirection, TransformGestureParams } from './scroll-gesture.t import type { SettingOptions } from '../platforms/permission-utils.ts'; import type { SessionSurface } from './session-surface.ts'; import type { BackendSnapshotResult } from '../backend.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; import type { RawSnapshotNode, SnapshotBackend, @@ -19,6 +20,7 @@ export type RunnerContext = { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; /** Subset of {@link RunnerContext} forwarded to runner command invocations. */ @@ -31,6 +33,7 @@ export type RunnerCallOptions = Pick< | 'iosXctestrunFile' | 'iosXctestDerivedDataPath' | 'iosXctestEnvDir' + | 'runnerLeaseContext' >; export type { BackMode }; diff --git a/src/core/lease-scope.ts b/src/core/lease-scope.ts new file mode 100644 index 000000000..690623b4a --- /dev/null +++ b/src/core/lease-scope.ts @@ -0,0 +1,252 @@ +import type { LeaseBackend } from '../contracts.ts'; +import { stripUndefined } from '../utils/parsing.ts'; + +const PROXY_LEASE_PROVIDER = 'proxy'; +export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; + +const REQUIRED_PROXY_LEASE_FIELDS = [ + 'leaseId', + 'tenantId', + 'runId', + 'clientId', + 'deviceKey', +] as const satisfies readonly (keyof LeaseScope)[]; + +export type LeaseScope = { + tenantId?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +export type LeaseDiagnosticsContext = Omit; + +export type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; + +export type LeaseAllocateRequestScope = { + tenantId: string; + runId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +export type LeaseScopedRequestScope = { + leaseId: string; + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +type LeaseRequestLike = { + flags?: Record; + meta?: { + tenantId?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + }; +}; + +type LeaseOptionsLike = { + tenant?: string; + runId?: string; + leaseId?: string; + leaseTtlMs?: number; + ttlMs?: number; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; +}; + +export function leaseScopeFromRequest(req: LeaseRequestLike): LeaseScope { + return stripUndefined({ + tenantId: req.meta?.tenantId ?? readFlagString(req.flags, 'tenant'), + runId: req.meta?.runId ?? readFlagString(req.flags, 'runId'), + leaseId: req.meta?.leaseId ?? readFlagString(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 leaseScopeFromOptions(options: LeaseOptionsLike): LeaseScope { + return stripUndefined({ + tenantId: options.tenant, + runId: options.runId, + leaseId: options.leaseId, + leaseTtlMs: options.leaseTtlMs ?? options.ttlMs, + leaseBackend: options.leaseBackend, + leaseProvider: options.leaseProvider ?? options.provider, + deviceKey: options.deviceKey, + clientId: options.clientId, + }); +} + +export function leaseScopeToRequestMeta(scope: LeaseScope): LeaseRequestLike['meta'] { + return stripUndefined({ + tenantId: scope.tenantId, + runId: scope.runId, + leaseId: scope.leaseId, + leaseTtlMs: scope.leaseTtlMs, + leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + }); +} + +export function leaseScopeToCommandFlags(scope: LeaseScope): Record { + return stripUndefined({ + tenant: scope.tenantId, + runId: scope.runId, + leaseId: scope.leaseId, + leaseBackend: scope.leaseBackend, + }); +} + +export function leaseScopeToAllocateRequest(scope: LeaseScope): LeaseAllocateRequestScope { + return stripUndefined({ + tenantId: scope.tenantId ?? '', + runId: scope.runId ?? '', + leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + ttlMs: scope.leaseTtlMs, + }) as LeaseAllocateRequestScope; +} + +export function leaseScopeToHeartbeatRequest(scope: LeaseScope): LeaseScopedRequestScope { + return leaseScopeToScopedRequest(scope); +} + +export function leaseScopeToReleaseRequest( + scope: LeaseScope, +): Omit { + const { ttlMs: _ttlMs, ...request } = leaseScopeToScopedRequest(scope); + return request; +} + +export function leaseScopeToLeaseRpcParams( + scope: LeaseScope, + command: LeaseRpcCommand, + options: { + includeTokenParam: boolean; + token?: string; + session?: string; + }, +): Record { + const common = stripUndefined({ + ...(options.includeTokenParam ? { token: options.token } : {}), + session: options.session, + tenantId: scope.tenantId, + runId: scope.runId, + leaseProvider: scope.leaseProvider, + clientId: scope.clientId, + deviceKey: scope.deviceKey, + }); + switch (command) { + case 'lease_allocate': + return { + ...common, + ...stripUndefined({ + ttlMs: scope.leaseTtlMs, + backend: scope.leaseBackend, + }), + }; + case 'lease_heartbeat': + return { + ...common, + ...stripUndefined({ + leaseId: scope.leaseId, + ttlMs: scope.leaseTtlMs, + }), + }; + case 'lease_release': + return { + ...common, + ...stripUndefined({ + leaseId: scope.leaseId, + }), + }; + } +} + +export function leaseScopeToConnectionMetadata( + scope: LeaseScope, +): Pick | undefined { + const connection = stripUndefined({ + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + }); + return Object.keys(connection).length > 0 ? connection : undefined; +} + +export function buildLeaseDiagnosticsContext( + leaseScope: LeaseScope | 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; +} + +export function isProxyLeaseScope(scope: LeaseScope): boolean { + return scope.leaseProvider === PROXY_LEASE_PROVIDER; +} + +export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { + if (!isProxyLeaseScope(scope)) return []; + return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); +} + +function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope { + return stripUndefined({ + leaseId: scope.leaseId ?? '', + tenantId: scope.tenantId, + runId: scope.runId, + leaseBackend: scope.leaseBackend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + clientId: scope.clientId, + ttlMs: scope.leaseTtlMs, + }) as LeaseScopedRequestScope; +} + +function readFlagString( + flags: Record | undefined, + key: string, +): string | undefined { + const value = flags?.[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/src/core/runner-lease-context.ts b/src/core/runner-lease-context.ts new file mode 100644 index 000000000..d6dd5c411 --- /dev/null +++ b/src/core/runner-lease-context.ts @@ -0,0 +1,8 @@ +export type RunnerLogicalLeaseContext = { + leaseId?: string; + clientId?: string; + tenantId?: string; + runId?: string; + leaseProvider?: string; + deviceKey?: string; +}; diff --git a/src/core/viewport-dimension.ts b/src/core/viewport-dimension.ts new file mode 100644 index 000000000..46879f868 --- /dev/null +++ b/src/core/viewport-dimension.ts @@ -0,0 +1,12 @@ +import { AppError } from '../utils/errors.ts'; + +export function readViewportDimension( + value: string | undefined, + label: 'width' | 'height', +): number { + const parsed = value === undefined ? NaN : Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`); + } + return parsed; +} diff --git a/src/daemon-client-rpc.ts b/src/daemon-client-rpc.ts index a0b21a531..74bc4b435 100644 --- a/src/daemon-client-rpc.ts +++ b/src/daemon-client-rpc.ts @@ -3,6 +3,11 @@ import { createRequestId } from './utils/diagnostics.ts'; import type { DaemonRequest, DaemonResponse } from './daemon/types.ts'; import { materializeRemoteArtifacts } from './daemon-artifacts.ts'; import type { DaemonInfo } from './daemon-client-metadata.ts'; +import { + leaseScopeFromRequest, + leaseScopeToLeaseRpcParams, + type LeaseRpcCommand, +} from './core/lease-scope.ts'; export function handleDaemonHttpResponseBody( body: string, @@ -114,8 +119,6 @@ export function buildHttpRpcPayload( }; } -type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; - function isLeaseRpcCommand(command: string): command is LeaseRpcCommand { return ( command === 'lease_allocate' || command === 'lease_heartbeat' || command === 'lease_release' @@ -138,29 +141,9 @@ function buildLeaseRpcParams( command: LeaseRpcCommand, options: { includeTokenParam: boolean }, ): Record { - const common = { - ...(options.includeTokenParam ? { token: req.token } : {}), + return leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { + includeTokenParam: options.includeTokenParam, + token: req.token, session: req.session, - tenantId: req.meta?.tenantId, - runId: req.meta?.runId, - }; - switch (command) { - case 'lease_allocate': - return { - ...common, - ttlMs: req.meta?.leaseTtlMs, - backend: req.meta?.leaseBackend, - }; - case 'lease_heartbeat': - return { - ...common, - leaseId: req.meta?.leaseId, - ttlMs: req.meta?.leaseTtlMs, - }; - case 'lease_release': - return { - ...common, - leaseId: req.meta?.leaseId, - }; - } + }); } diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index 3c7431757..728d5bc00 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -7,7 +7,7 @@ import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; import { LeaseRegistry } from './daemon/lease-registry.ts'; import { createRequestHandler } from './daemon/request-router.ts'; -import { teardownSessionResources } from './daemon/handlers/session-close.ts'; +import { teardownSessionResources } from './daemon/session-teardown.ts'; import { closeDaemonServers } from './daemon/server-shutdown.ts'; import type { SessionState } from './daemon/types.ts'; import { @@ -33,6 +33,7 @@ import { } from './daemon/transport.ts'; import { prewarmPngWorker, terminatePngWorker } from './utils/png-worker-client.ts'; import { sleep } from './utils/timeouts.ts'; +import { setRunnerLeaseOwnerStateDir } from './platforms/ios/runner-lease.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -66,6 +67,7 @@ export async function startDaemonRuntime( const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR); const { baseDir, infoPath, lockPath, logPath, sessionsDir } = daemonPaths; const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); + setRunnerLeaseOwnerStateDir(baseDir); cleanupStaleAppLogProcesses(sessionsDir); @@ -182,6 +184,7 @@ export async function startDaemonRuntime( }; if (!acquireDaemonLock(baseDir, lockPath, lockData)) { stderr.write('Daemon lock is held by another process; exiting.\n'); + setRunnerLeaseOwnerStateDir(undefined); exit(0); return null; } @@ -201,6 +204,7 @@ export async function startDaemonRuntime( closeServersBestEffort(servers); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(1); return null; } @@ -228,6 +232,7 @@ export async function startDaemonRuntime( ]); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(shutdownOptions.exitCode ?? 0); }; diff --git a/src/daemon/__tests__/lease-context.test.ts b/src/daemon/__tests__/lease-context.test.ts new file mode 100644 index 000000000..c90a91793 --- /dev/null +++ b/src/daemon/__tests__/lease-context.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + buildLeaseDiagnosticsContext, + buildSessionLeaseFromRequest, + resolveRunnerLogicalLeaseContext, + 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); +}); + +test('resolveRunnerLogicalLeaseContext keeps lease backend separate from provider', () => { + const context = resolveRunnerLogicalLeaseContext({ + meta: { + leaseId: 'lease-1', + leaseBackend: 'ios-instance', + tenantId: 'tenant-a', + runId: 'run-1', + }, + }); + + assert.deepEqual(context, { + leaseId: 'lease-1', + tenantId: 'tenant-a', + runId: 'run-1', + }); +}); diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts new file mode 100644 index 000000000..0eea3d4af --- /dev/null +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -0,0 +1,160 @@ +import { test, expect, vi } from 'vitest'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { + admitRequestLeaseForLockedScope, + cleanupExpiredLeasedSession, + releaseSessionLease, + resolveSessionLeaseForRequest, +} from '../lease-lifecycle.ts'; +import type { DaemonRequest } from '../types.ts'; + +test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on the request', () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-lease-lifecycle-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }), + ); + now = 2_000; + + const req = admitRequestLeaseForLockedScope({ + req: makeRequest({ command: 'snapshot' }), + sessionName: 'default', + sessionStore, + leaseRegistry, + }); + + expect(req.internal?.admittedLease?.leaseId).toBe(lease.leaseId); + expect(req.internal?.admittedLease?.heartbeatAt).toBe(2_000); + expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); +}); + +test('cleanupExpiredLeasedSession consumes expired lease and deletes the session after teardown', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-lease-lifecycle-'); + const leaseRegistry = new LeaseRegistry({ + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + expiresAt: lease.expiresAt, + }, + }); + sessionStore.set('default', session); + now = 1_011; + const teardownSession = vi.fn(async () => {}); + + const cleaned = await cleanupExpiredLeasedSession({ + sessionName: 'default', + sessionStore, + leaseRegistry, + teardownSession, + }); + + expect(cleaned).toBe(true); + expect(teardownSession).toHaveBeenCalledWith(session, 'default'); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('releaseSessionLease releases with the stored session owner scope', () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }); + + releaseSessionLease({ session, leaseRegistry }); + + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('resolveSessionLeaseForRequest prefers admitted lease and falls back to existing lease', () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const req = makeRequest({ + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: lease.leaseId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }, + internal: { admittedLease: lease }, + }); + + const resolved = resolveSessionLeaseForRequest({ + req, + existingLease: { + leaseId: 'older', + tenantId: 'tenant-a', + runId: 'run-1', + }, + }); + + expect(resolved?.leaseId).toBe(lease.leaseId); + expect(resolved?.expiresAt).toBe(lease.expiresAt); +}); + +function makeRequest(overrides: Partial = {}): DaemonRequest { + return { + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + ...overrides, + }; +} diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index f260a3a0e..0fef548ff 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -98,3 +98,260 @@ 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', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + now = 3_000; + const second = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + assert.equal(second.leaseId, first.leaseId); + assert.equal(second.leaseProvider, '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(); + registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + const error = captureThrown(() => + registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }), + ); + + assert.ok(error instanceof Error); + assert.equal(error.message, 'Device is already leased'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'DEVICE_LEASE_BUSY'); + assert.equal(details?.leaseId, undefined); + assert.equal(details?.tenantId, undefined); + assert.equal(details?.runId, undefined); +}); + +test('same run/provider/device with different client reports device busy', () => { + const registry = new LeaseRegistry(); + registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'shared-run', + leaseBackend: 'ios-instance', + leaseProvider: 'cloud', + deviceKey: 'device-1', + clientId: 'client-a', + }); + + const error = captureThrown(() => + registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'shared-run', + leaseBackend: 'ios-instance', + leaseProvider: 'cloud', + deviceKey: 'device-1', + clientId: 'client-b', + }), + ); + + assert.ok(error instanceof Error); + assert.equal(error.message, 'Device is already leased'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'DEVICE_LEASE_BUSY'); + assert.equal(details?.deviceKey, 'device-1'); + assert.equal(details?.leaseProvider, 'cloud'); +}); + +test('device leases are isolated by provider and device key', () => { + const registry = new LeaseRegistry(); + const proxy = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + const limrun = registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + deviceKey: 'device-1', + }); + const secondDevice = registry.allocateLease({ + tenantId: 'tenant-c', + runId: 'run-3', + leaseBackend: '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, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-2', + clientId: 'client-a', + }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => + registry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + deviceKey: 'device-1', + clientId: 'client-a', + }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); + assert.throws( + () => + registry.heartbeatLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + clientId: 'client-b', + }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_MISMATCH', + ); +}); + +test('heartbeat/release require owner scope for device-aware leases', () => { + 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 }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_REQUIRED', + ); + assert.throws( + () => + registry.releaseLease({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }), + (error) => + error instanceof Error && + (error as { details?: Record }).details?.reason === 'LEASE_SCOPE_REQUIRED', + ); +}); + +test('consumeExpiredLease removes one expired lease without sweeping unrelated sessions', () => { + let now = 1_000; + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 5_000, + }); + const first = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const second = registry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + + now = 7_000; + const expired = registry.consumeExpiredLease(first.leaseId); + + assert.equal(expired?.leaseId, first.leaseId); + assert.equal(registry.consumeExpiredLease(second.leaseId)?.leaseId, second.leaseId); + assert.deepEqual(registry.consumeExpiredLease(first.leaseId), undefined); +}); + +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', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + now = 7_000; + const second = registry.allocateLease({ + tenantId: 'tenant-b', + runId: 'run-2', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + assert.notEqual(second.leaseId, first.leaseId); +}); + +function captureThrown(task: () => unknown): unknown { + try { + task(); + return undefined; + } catch (error) { + return error; + } +} diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index fa1749aaf..424af3094 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -6,7 +6,9 @@ import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../utils import { makeAndroidSession, makeIosSession, + makeSession, } from '../../__tests__/test-utils/session-factories.ts'; +import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { clearRequestCanceled, markRequestCanceled } from '../request-cancel.ts'; @@ -24,10 +26,16 @@ afterAll(() => { fs.rmSync(TEST_ROOT, { recursive: true, force: true }); }); -test('createRequestExecutionScope applies tenant scoping and lease admission', async () => { +test('createRequestExecutionScope applies tenant scoping and locked lease admission', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); - const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); const scope = await createRequestExecutionScope({ req: makeRequest({ @@ -37,6 +45,9 @@ test('createRequestExecutionScope applies tenant scoping and lease admission', a tenantId: 'tenant-a', runId: 'run-1', leaseId: lease.leaseId, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', sessionIsolation: 'tenant', }, }), @@ -47,6 +58,10 @@ test('createRequestExecutionScope applies tenant scoping and lease admission', a expect(scope.req.session).toBe('tenant-a:default'); expect(scope.req.meta?.tenantId).toBe('tenant-a'); expect(scope.sessionName).toBe('tenant-a:default'); + const admittedLeaseId = await scope.runLocked( + async () => scope.req.internal?.admittedLease?.leaseId, + ); + expect(admittedLeaseId).toBe(lease.leaseId); }); test('createRequestExecutionScope resolves session-scoped request and runner log paths', async () => { @@ -95,23 +110,347 @@ test('request diagnostics flush into the effective session request log', async ( expect(fs.readFileSync(result.expectedPath, 'utf8')).toContain('"phase":"request_start"'); }); -test('createRequestExecutionScope rejects tenant requests without an active lease', async () => { - await expect( - createRequestExecutionScope({ - req: makeRequest({ - session: 'default', - command: 'snapshot', - meta: { - tenantId: 'tenant-a', - runId: 'run-1', - leaseId: '0'.repeat(32), - sessionIsolation: 'tenant', - }, +test('runLocked rejects tenant requests without an active lease', async () => { + const scope = await createRequestExecutionScope({ + req: makeRequest({ + session: 'default', + command: 'snapshot', + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant', + }, + }), + sessionStore: makeSessionStore('agent-device-request-scope-'), + leaseRegistry: new LeaseRegistry(), + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow(/Lease is not active/); +}); + +test('leased session admission uses stored lease metadata and heartbeats', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 2_000; + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + await scope.runLocked(async () => 'ran'); + + expect(scope.sessionName).toBe('default'); + const activeLease = leaseRegistry.listActiveLeases()[0]; + expect(activeLease?.heartbeatAt).toBe(2_000); + expect(activeLease?.expiresAt).toBe(302_000); + expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); +}); + +test('leased session heartbeat is serialized with the request execution lock', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); + + const first = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + const second = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + + let releaseFirst: () => void = () => {}; + let firstEntered: () => void = () => {}; + const firstEnteredPromise = new Promise((resolve) => { + firstEntered = resolve; + }); + now = 2_000; + const firstRun = first.runLocked( + async () => + await new Promise((release) => { + releaseFirst = release; + firstEntered(); }), - sessionStore: makeSessionStore('agent-device-request-scope-'), - leaseRegistry: new LeaseRegistry(), + ); + await firstEnteredPromise; + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(2_000); + + now = 3_000; + const secondRun = second.runLocked(async () => 'second'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(2_000); + + releaseFirst(); + await firstRun; + await expect(secondRun).resolves.toBe('second'); + expect(leaseRegistry.listActiveLeases()[0]?.heartbeatAt).toBe(3_000); +}); + +test('leased session rejects mismatched lease id before dispatch', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + }, + }), + ); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), + sessionStore, + leaseRegistry, + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow( + /Lease does not match session owner \(leaseId\)/, + ); +}); + +test.each([ + ['leaseProvider', { leaseProvider: 'cloud' }], + ['clientId', { clientId: 'client-b' }], + ['deviceKey', { deviceKey: 'ios:SIM-002' }], +] as const)('leased session rejects mismatched %s before dispatch', async (_field, meta) => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-001', + }, + }), + ); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta }), + sessionStore, + leaseRegistry, + }); + + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow( + /Lease does not match session owner/, + ); +}); + +test('local unleased session admission still succeeds', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('local unleased session ignores stale lease id without tenant scope', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + const scope = await createRequestExecutionScope({ + req: makeRequest({ + command: 'snapshot', + meta: { leaseId: '1'.repeat(32) }, + }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + await expect(scope.runLocked(async () => 'ran')).resolves.toBe('ran'); +}); + +test('provider lease admission succeeds without a device key', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + }); + sessionStore.set( + 'default', + makeAndroidSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'limrun', + }, + }), + ); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('expired leases remove owned sessions before the next command and free capacity', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + maxActiveSimulatorLeases: 1, + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeSession('default', { + device: LINUX_DEVICE, + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: lease.expiresAt, + }, }), - ).rejects.toThrow(/Lease is not active/); + ); + now = 1_011; + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + await scope.runLocked(async () => 'ran'); + + expect(sessionStore.get('default')).toBeUndefined(); + const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + expect(nextLease.tenantId).toBe('tenant-b'); +}); + +test('expired leased session cleanup waits for the request execution lock', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + expiresAt: lease.expiresAt, + }, + }), + ); + const first = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + const second = await createRequestExecutionScope({ + req: makeRequest({ command: 'click' }), + sessionStore, + leaseRegistry, + }); + + let releaseFirst: () => void = () => {}; + let firstEntered: () => void = () => {}; + const firstEnteredPromise = new Promise((resolve) => { + firstEntered = resolve; + }); + const firstRun = first.runLocked( + async () => + await new Promise((release) => { + releaseFirst = release; + firstEntered(); + }), + ); + await firstEnteredPromise; + + now = 1_011; + const secondRun = second.runLocked(async () => 'second'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sessionStore.get('default')).toBeDefined(); + + releaseFirst(); + await firstRun; + await expect(secondRun).resolves.toBe('second'); + expect(sessionStore.get('default')).toBeUndefined(); }); test('tenant lease rejection flushes diagnostics into the effective session request log', async () => { @@ -120,23 +459,22 @@ test('tenant lease rejection flushes diagnostics into the effective session requ let flushedPath: string | null = null; await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { - await expect( - createRequestExecutionScope({ - req: makeRequest({ - session: 'default', - command: 'snapshot', - meta: { - tenantId: 'tenant-a', - runId: 'run-1', - leaseId: '0'.repeat(32), - sessionIsolation: 'tenant', - requestId, - }, - }), - sessionStore, - leaseRegistry: new LeaseRegistry(), + const scope = await createRequestExecutionScope({ + req: makeRequest({ + session: 'default', + command: 'snapshot', + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant', + requestId, + }, }), - ).rejects.toThrow(/Lease is not active/); + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + await expect(scope.runLocked(async () => 'ran')).rejects.toThrow(/Lease is not active/); flushedPath = flushDiagnosticsToSessionFile({ force: true }); }); 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/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 4d129dd8e..f34335a97 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -29,24 +29,32 @@ function makeIosDevice(id: string): DeviceInfo { }; } -function createOpenHandler(sessionStore: ReturnType) { +function createOpenHandler( + sessionStore: ReturnType, + leaseRegistry = new LeaseRegistry(), +) { return createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, - leaseRegistry: new LeaseRegistry(), + leaseRegistry, trackDownloadableArtifact: () => 'artifact-id', }); } -function openRequest(session: string, flags: Record, requestId: string) { +function openRequest( + session: string, + flags: Record, + requestId: string, + meta: Record = {}, +) { return { token: 'test-token', session, command: 'open', positionals: [], flags, - meta: { requestId }, + meta: { requestId, ...meta }, }; } @@ -58,6 +66,7 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); }); +// fallow-ignore-next-line complexity test('open returns and creates the session state directory', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const device = makeIosDevice('SIM-STATE'); @@ -82,6 +91,142 @@ test('open returns and creates the session state directory', async () => { } }); +test('open stores admitted lease metadata on the session', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry({ now: () => 1_000 }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + }); + const device = makeIosDevice('SIM-LEASED'); + mockResolveTargetDevice.mockResolvedValue(device); + + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-lease', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: lease.leaseId, + sessionIsolation: 'tenant', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + leaseBackend: 'ios-simulator', + }), + ); + + expect(response.ok).toBe(true); + expect(sessionStore.get('tenant-a:default')?.lease).toEqual({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-simulator', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + expiresAt: 301_000, + }); +}); + +test('proxy open without required lease metadata fails before device resolution', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const handler = createOpenHandler(sessionStore, new LeaseRegistry()); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-proxy-missing', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + sessionIsolation: 'tenant', + }), + ); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/Proxy open requires leaseId/); + } + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test('close releases the session lease', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-lease' }, + }); + + expect(response.ok).toBe(true); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('close rejects a different client before cleanup', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE-CLIENT'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-wrong-client', clientId: 'client-b' }, + }); + + expect(response.ok).toBe(false); + expect(sessionStore.get('default')).toBeDefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + test('router serializes same-device open requests before first session creation finishes', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const sameDevice = makeIosDevice('SIM-001'); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index c7eacdfb1..3c3fe4abd 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -108,6 +108,32 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); +test('session lease metadata round-trips through the store', () => { + const { store, session } = makeFixture('agent-device-session-lease-'); + session.lease = { + leaseId: 'f'.repeat(32), + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + leaseBackend: 'ios-simulator', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: 123_456, + }; + + store.set(session.name, session); + + assert.deepEqual(store.get(session.name)?.lease, session.lease); +}); + +test('sessions without lease metadata remain valid', () => { + const { store, session } = makeFixture('agent-device-session-unleased-'); + + store.set(session.name, session); + + assert.equal(store.get(session.name)?.lease, undefined); +}); + test('saveScript flag enables .ad session log writing', () => { const { root, store, session } = makeFixture('agent-device-session-log-enabled-'); recordOpen(store, session); diff --git a/src/daemon/context.ts b/src/daemon/context.ts index ffd99cfd3..e7c8869b0 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -5,6 +5,8 @@ import { type ScreenshotRuntimeFlags, } from '../contracts/screenshot.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import { resolveRunnerLogicalLeaseContext } from './lease-context.ts'; +import type { DaemonRequest } from './types.ts'; export type DaemonCommandContext = DispatchContext & ScreenshotRuntimeFlags; @@ -17,11 +19,13 @@ export function contextFromFlags( appBundleId?: string, traceLogPath?: string, requestId?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const effectiveRequestId = requestId ?? getDiagnosticsMeta().requestId; return { requestId: effectiveRequestId, appBundleId, + runnerLeaseContext: resolveRunnerLogicalLeaseContext({ meta }), activity: flags?.activity, launchConsole: flags?.launchConsole, launchArgs: flags?.launchArgs, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index e535f797a..c089b9f32 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -124,7 +124,7 @@ const DAEMON_COMMAND_DESCRIPTORS = [ descriptor(PUBLIC_COMMANDS.record, 'recordTrace', { replayScopedAction: true, allowInvalidRecording: true, - allowSessionlessDefaultDevice: isRecordStartRequest, + allowSessionlessDefaultDevice: isRecordingStartRequest, }), descriptor(PUBLIC_COMMANDS.trace, 'recordTrace'), descriptor(PUBLIC_COMMANDS.find, 'find', { replayScopedAction: true }), @@ -261,7 +261,7 @@ function buildDaemonCommandRegistry(descriptors: readonly DaemonCommandDescripto return { descriptorsByCommand }; } -function isRecordStartRequest(req: DaemonRequest): boolean { +function isRecordingStartRequest(req: DaemonRequest): boolean { return (req.positionals?.[0] ?? '').toLowerCase() === 'start'; } diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 9e1e23420..f3953f628 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -49,7 +49,7 @@ vi.mock('../session-device-utils.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../session.ts'; -import { teardownSessionResources } from '../session-close.ts'; +import { teardownSessionResources } from '../../session-teardown.ts'; import { shutdownSimulator } from '../../../platforms/ios/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 494ba942e..69d0047f9 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session-replay.ts'; @@ -244,6 +245,7 @@ test('test --record-video records each replay attempt on the generated test sess sessionName: 'default', logPath: path.join(root, 'daemon.log'), sessionStore, + leaseRegistry: new LeaseRegistry(), invoke: async (nestedReq) => { nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index ad36e01ac..5e677643f 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,6 +1,11 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { resolveLeaseScope } from '../lease-context.ts'; +import { + leaseScopeToAllocateRequest, + leaseScopeToHeartbeatRequest, + leaseScopeToReleaseRequest, +} from '../../core/lease-scope.ts'; type LeaseHandlerArgs = { req: DaemonRequest; @@ -12,35 +17,21 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { - await stopIosRunnerSession(session.device.id); - if (session.device.platform !== 'macos') { - return; - } - - const dismissOptions = - session.surface === 'frontmost-app' - ? { surface: 'frontmost-app' as const } - : session.appBundleId - ? { bundleId: session.appBundleId } - : {}; - await runMacOsAlertAction('dismiss', dismissOptions).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'macos_close_alert_dismiss_failed', - data: { - session: session.name, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); -} - function shouldRetainAppleRunnerAfterClose(req: DaemonRequest, session: SessionState): boolean { return isIosSimulator(session.device) && !req.flags?.shutdown && !session.recording; } @@ -68,102 +47,74 @@ function shouldStopAppleRunnerBeforeTargetedClose(session: SessionState): boolea return isApplePlatform(session.device.platform) && !isIosSimulator(session.device); } -async function stopSessionApplePerfCapture(session: SessionState): Promise { - if (!session.applePerf?.active) return; - await cleanupAppleXctracePerfCapture(session.applePerf.active); - session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; -} - -async function stopSessionAndroidNativePerfCapture(session: SessionState): Promise { - const active = session.nativePerf?.android; - if (!active) return; - await cleanupAndroidNativePerfSession(session.device, active); - session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined }; -} - -async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise { - if (session.device.platform !== 'android') return; - await stopAndroidSnapshotHelperSessionForDevice(session.device); -} - -export async function teardownSessionResources( - session: SessionState, - sessionName: string, -): Promise { - if (session.appLog) { - await stopAppLog(session.appLog); - } - await stopSessionApplePerfCapture(session); - await stopSessionAndroidNativePerfCapture(session); - await stopSessionAndroidSnapshotHelper(session); - if (isApplePlatform(session.device.platform)) { - await stopAppleRunnerForClose(session); - } - await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); -} - export async function handleCloseCommand(params: { req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; }): Promise { - const { req, sessionName, logPath, sessionStore } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry } = params; const session = sessionStore.get(sessionName); if (!session) { return await closeWithoutSession(req, logPath); } - if (session.appLog) { - await stopAppLog(session.appLog); - } - await stopSessionApplePerfCapture(session); - await stopSessionAndroidNativePerfCapture(session); - await stopSessionAndroidSnapshotHelper(session); - if (shouldDispatchPlatformClose(req, session)) { - if (shouldStopAppleRunnerBeforeTargetedClose(session)) { + try { + await stopSessionAppLog(session); + await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); + await stopSessionAndroidSnapshotHelper(session); + if (shouldDispatchPlatformClose(req, session)) { + if (shouldStopAppleRunnerBeforeTargetedClose(session)) { + await stopAppleRunnerForClose(session); + } + await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { + ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), + }); + await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS); + } + if ( + isApplePlatform(session.device.platform) && + !shouldRetainAppleRunnerAfterClose(req, session) + ) { + // The targeted close path stops before dispatch to avoid runner/app races. + // Stop again here for idempotent cleanup, and keep cleanup-sensitive closes explicit. await stopAppleRunnerForClose(session); + } else if (isApplePlatform(session.device.platform)) { + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_retained_after_close', + data: { + session: session.name, + deviceId: session.device.id, + }, + }); } - await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), - }); - await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS); - } - if ( - isApplePlatform(session.device.platform) && - !shouldRetainAppleRunnerAfterClose(req, session) - ) { - // The targeted close path stops before dispatch to avoid runner/app races. - // Stop again here for idempotent cleanup, and keep cleanup-sensitive closes explicit. - await stopAppleRunnerForClose(session); - } else if (isApplePlatform(session.device.platform)) { - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_retained_after_close', - data: { - session: session.name, - deviceId: session.device.id, - }, + const runtime = sessionStore.getRuntimeHints(sessionName); + if (hasRuntimeTransportHints(runtime) && session.appBundleId) { + await clearRuntimeHintsFromApp({ + device: session.device, + appId: session.appBundleId, + }).catch(() => {}); + } + sessionStore.recordAction(session, { + command: 'close', + positionals: req.positionals ?? [], + flags: req.flags ?? {}, + result: { session: session.name, ...successText(`Closed: ${session.name}`) }, }); + if (req.flags?.saveScript) { + session.recordSession = true; + } + sessionStore.writeSessionLog(session); + await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); + } finally { + // Always release the device lease and drop the session, even if teardown + // above threw: a failed close must not strand device ownership until the + // inactivity expiry. The original error still propagates after finally. + releaseSessionLease({ session, leaseRegistry }); + sessionStore.delete(sessionName); } - const runtime = sessionStore.getRuntimeHints(sessionName); - if (hasRuntimeTransportHints(runtime) && session.appBundleId) { - await clearRuntimeHintsFromApp({ - device: session.device, - appId: session.appBundleId, - }).catch(() => {}); - } - sessionStore.recordAction(session, { - command: 'close', - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { session: session.name, ...successText(`Closed: ${session.name}`) }, - }); - if (req.flags?.saveScript) { - session.recordSession = true; - } - sessionStore.writeSessionLog(session); - await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); - sessionStore.delete(sessionName); const shutdownResult = await maybeShutdownSessionTarget({ device: session.device, shutdownRequested: req.flags?.shutdown, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 2fd6e29e0..ed50b116d 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,6 +43,7 @@ import { resolveImplicitSessionScope, resolvePublicSessionName, } from '../session-routing.ts'; +import { resolveSessionLeaseForRequest } from '../lease-lifecycle.ts'; const firstSessionOpenLocks = new Map>(); @@ -188,6 +189,14 @@ async function completeOpenCommand(params: { logPath, traceLogPath, requestId: req.meta?.requestId, + runnerLeaseContext: contextFromFlags( + logPath, + req.flags, + sessionAppBundleId, + traceLogPath, + req.meta?.requestId, + req.meta, + ).runnerLeaseContext, iosXctestrunFile: req.flags?.iosXctestrunFile, iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, iosXctestEnvDir: req.flags?.iosXctestEnvDir, @@ -279,6 +288,10 @@ async function completeOpenCommand(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + nextSession.lease = resolveSessionLeaseForRequest({ + req, + existingLease: existingSession?.lease, + }); if (req.runtime !== undefined) { setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime); } @@ -347,6 +360,10 @@ async function prepareOpenDispatchSession(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + provisionalSession.lease = resolveSessionLeaseForRequest({ + req, + existingLease: existingSession?.lease, + }); sessionStore.set(sessionName, provisionalSession); const lifecycleResponse = await beforeDispatch(provisionalSession); if (lifecycleResponse && !lifecycleResponse.ok) { diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 8f72c439a..e3e9df950 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -6,6 +6,7 @@ import { handleCloseCommand } from './session-close.ts'; import { collectReplayActionArtifactPaths, runReplayScriptFile } from './session-replay-runtime.ts'; import type { ReplayScriptMetadata } from '../../replay/script.ts'; import { buildReplayTestShardFlags, type ReplayTestShardContext } from './session-test-sharding.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; import { buildReplayTestVideoOpenLifecycle, finalizeReplayTestVideoRecording, @@ -52,9 +53,10 @@ export async function handleSessionReplayCommands(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; invoke: DaemonInvokeFn; }): Promise { - const { req, sessionName, logPath, sessionStore, invoke } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; if (req.command === 'replay') { return await runReplayScriptFile({ @@ -166,6 +168,7 @@ export async function handleSessionReplayCommands(params: { sessionName: testSessionName, logPath, sessionStore, + leaseRegistry, }); }, }); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 9d30f903e..0492edcf4 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -36,6 +36,7 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { getSessionCommandKind } from '../daemon-command-registry.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; @@ -247,6 +248,7 @@ export async function handleSessionCommands(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry?: LeaseRegistry; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; @@ -256,6 +258,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry = new LeaseRegistry(), invoke, invokeReplayAction, androidAdbExecutor, @@ -421,6 +424,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry, invoke: invokeReplayAction ?? invoke, }); } @@ -435,6 +439,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry, }); } diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 6f4600a36..e10b5660d 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -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'), }, }; } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 763e309c4..e5c5aac03 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -1,20 +1,102 @@ import type { DaemonRequest } from './types.ts'; import type { LeaseBackend } from '../contracts.ts'; +import type { DeviceLease } from './lease-registry.ts'; +import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; +import { stripUndefined } from '../utils/parsing.ts'; +import { + DEFAULT_PROXY_LEASE_TTL_MS, + buildLeaseDiagnosticsContext, + findMissingProxyLeaseFields, + isProxyLeaseScope, + leaseScopeFromRequest, + type LeaseDiagnosticsContext, + type LeaseScope, +} from '../core/lease-scope.ts'; -export type LeaseScope = { - tenantId?: string; - runId?: string; - leaseId?: string; - leaseTtlMs?: number; +export { + DEFAULT_PROXY_LEASE_TTL_MS, + buildLeaseDiagnosticsContext, + findMissingProxyLeaseFields, + isProxyLeaseScope, +}; +export type { LeaseDiagnosticsContext, LeaseScope }; + +export type SessionLease = { + tenantId: string; + runId: string; + leaseId: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + expiresAt?: number; +}; + +type SessionLeaseSource = { + lease?: SessionLease | null; + deviceLease?: SessionLease | null; }; export function resolveLeaseScope(req: Pick): LeaseScope { - return { - tenantId: req.meta?.tenantId ?? req.flags?.tenant, - runId: req.meta?.runId ?? req.flags?.runId, - leaseId: req.meta?.leaseId ?? req.flags?.leaseId, - leaseTtlMs: req.meta?.leaseTtlMs, - leaseBackend: req.meta?.leaseBackend, - }; + return leaseScopeFromRequest(req); +} + +export function buildSessionLeaseFromRequest( + req: Pick, + activeLease?: DeviceLease, +): SessionLease | undefined { + const leaseScope = resolveLeaseScope(req); + const leaseId = leaseScope.leaseId ?? activeLease?.leaseId; + const tenantId = leaseScope.tenantId ?? activeLease?.tenantId; + const runId = leaseScope.runId ?? activeLease?.runId; + if (!tenantId || !runId || !leaseId) { + return undefined; + } + return stripUndefined({ + tenantId, + runId, + leaseId, + leaseBackend: leaseScope.leaseBackend ?? activeLease?.backend, + leaseProvider: leaseScope.leaseProvider ?? activeLease?.leaseProvider, + deviceKey: leaseScope.deviceKey ?? activeLease?.deviceKey, + clientId: leaseScope.clientId ?? activeLease?.clientId, + expiresAt: activeLease?.expiresAt, + }); +} + +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 resolveRunnerLogicalLeaseContext( + req: Pick, +): RunnerLogicalLeaseContext | undefined { + const meta = req.meta as (DaemonRequest['meta'] & Record) | undefined; + const context = stripUndefined({ + leaseId: readNonEmptyString(meta?.leaseId), + clientId: readNonEmptyString(meta?.clientId), + tenantId: readNonEmptyString(meta?.tenantId), + runId: readNonEmptyString(meta?.runId), + leaseProvider: readNonEmptyString(meta?.leaseProvider), + deviceKey: readNonEmptyString(meta?.deviceKey), + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts new file mode 100644 index 000000000..d7ce7f0ff --- /dev/null +++ b/src/daemon/lease-lifecycle.ts @@ -0,0 +1,122 @@ +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { leaseScopeToReleaseRequest } from '../core/lease-scope.ts'; +import type { LeaseRegistry } from './lease-registry.ts'; +import { buildSessionLeaseFromRequest, type SessionLease } from './lease-context.ts'; +import { + assertRequestLeaseAdmission, + assertRequestLeaseAdmissionPreflight, +} from './request-admission.ts'; +import type { SessionStore } from './session-store.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +export type SessionTeardown = (session: SessionState, sessionName: string) => Promise; + +export function assertLockedLeaseAdmissionPreflight(req: DaemonRequest): void { + assertRequestLeaseAdmissionPreflight(req); +} + +export async function cleanupExpiredLeasedSession(params: { + sessionName: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; + teardownSession: SessionTeardown; +}): Promise { + const session = params.sessionStore.get(params.sessionName); + const lease = session?.lease; + if (!session || !lease) return false; + const expiredLease = params.leaseRegistry.consumeExpiredLease(lease.leaseId); + if (!expiredLease) return false; + emitDiagnostic({ + level: 'info', + phase: 'leased_session_expired', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + deviceKey: lease.deviceKey, + }, + }); + await params.teardownSession(session, session.name).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'leased_session_expiry_cleanup_failed', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + params.sessionStore.delete(session.name); + return true; +} + +export function admitRequestLeaseForLockedScope(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; +}): DaemonRequest { + const { sessionName, sessionStore, leaseRegistry } = params; + const existingSession = sessionStore.get(sessionName); + const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); + if (!activeLease) return params.req; + + const nextReq = { + ...params.req, + internal: { + ...params.req.internal, + admittedLease: activeLease, + }, + }; + if (existingSession?.lease) { + sessionStore.set(sessionName, { + ...existingSession, + lease: { + ...existingSession.lease, + leaseBackend: activeLease.backend, + expiresAt: activeLease.expiresAt, + }, + }); + } + return nextReq; +} + +export function resolveSessionLeaseForRequest(params: { + req: Pick; + existingLease?: SessionLease; +}): SessionLease | undefined { + return ( + buildSessionLeaseFromRequest(params.req, params.req.internal?.admittedLease) ?? + params.existingLease + ); +} + +export function releaseSessionLease(params: { + session: SessionState; + leaseRegistry: LeaseRegistry; +}): void { + const lease = params.session.lease; + if (!lease) return; + const result = params.leaseRegistry.releaseLease( + leaseScopeToReleaseRequest({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }), + ); + emitDiagnostic({ + level: 'info', + phase: 'session_lease_released', + data: { + session: params.session.name, + leaseId: lease.leaseId, + released: result.released, + }, + }); +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3f5a4ba3f..aa58cec49 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -1,18 +1,23 @@ 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; + deviceKey?: string; + clientId?: string; createdAt: number; heartbeatAt: number; expiresAt: number; }; +export type SimulatorLease = DeviceLease; + export type LeaseRegistryOptions = { maxActiveSimulatorLeases?: number; defaultLeaseTtlMs?: number; @@ -24,7 +29,10 @@ export type LeaseRegistryOptions = { export type AllocateLeaseRequest = { tenantId: string; runId: string; - backend?: LeaseBackend; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -32,6 +40,10 @@ export type HeartbeatLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -39,18 +51,54 @@ export type ReleaseLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type AdmissionRequest = { - tenantId: string | undefined; - runId: string | undefined; - leaseId: string | undefined; - backend?: LeaseBackend; + tenantId?: string; + runId?: string; + leaseId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type LeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type NormalizedLeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type NormalizedAllocateLeaseRequest = { + tenantId: string; + runId: string; + backend: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; }; 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 +123,92 @@ 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 normalizeLeaseProvider(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'lease provider', 64); +} + +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; +} + +function normalizeRequiredTenantId(raw: string): string { + const tenantId = normalizeTenantId(raw); + if (!tenantId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return tenantId; +} + +function normalizeRequiredRunId(raw: string): string { + const runId = normalizeRunId(raw); + if (!runId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return runId; +} + +function normalizeAllocateLeaseRequest( + request: AllocateLeaseRequest, +): NormalizedAllocateLeaseRequest { + return { + backend: normalizeLeaseBackend(request.leaseBackend), + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + tenantId: normalizeRequiredTenantId(request.tenantId), + runId: normalizeRequiredRunId(request.runId), + ttlMs: request.ttlMs, + }; +} + +function leaseRequiresOwnerScope(lease: DeviceLease): boolean { + return Boolean(lease.leaseProvider ?? lease.deviceKey ?? lease.clientId); +} + +function hasRequiredOwnerScope(lease: DeviceLease, request: LeaseScopeMatchRequest): boolean { + if (!request.tenantId || !request.runId) return false; + return [ + [lease.leaseProvider, request.leaseProvider], + [lease.deviceKey, request.deviceKey], + [lease.clientId, request.clientId], + ].every(([leaseValue, requestValue]) => !leaseValue || Boolean(requestValue)); +} + 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,84 +231,84 @@ export class LeaseRegistry { this.now = options.now ?? (() => Date.now()); } - allocateLease(request: AllocateLeaseRequest): SimulatorLease { - const backend = normalizeLeaseBackend(request.backend); - const tenantId = normalizeTenantId(request.tenantId); - if (!tenantId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - const runId = normalizeRunId(request.runId); - if (!runId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } + allocateLease(request: AllocateLeaseRequest): DeviceLease { + const normalized = normalizeAllocateLeaseRequest(request); this.cleanupExpiredLeases(); - const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); - const bindingKey = this.bindingKey(tenantId, runId, backend); + const leaseTtlMs = this.resolveLeaseTtlMs(normalized.ttlMs); + const existingLease = this.refreshExistingRunBinding(normalized, leaseTtlMs); + if (existingLease) return existingLease; + this.assertDeviceAvailable(normalized); + this.enforceCapacity(normalized.backend); + const lease = this.createLease(normalized, leaseTtlMs); + this.leases.set(lease.leaseId, lease); + this.bindLease(lease); + return { ...lease }; + } + + private refreshExistingRunBinding( + request: NormalizedAllocateLeaseRequest, + leaseTtlMs: number, + ): DeviceLease | undefined { + const bindingKey = this.bindingKey(request); const existingId = this.runBindings.get(bindingKey); - if (existingId) { - const existingLease = this.leases.get(existingId); - if (existingLease) { - return this.refreshLease(existingLease, leaseTtlMs); - } + if (!existingId) return undefined; + const existingLease = this.leases.get(existingId); + if (!existingLease) { this.runBindings.delete(bindingKey); + return undefined; } - this.enforceCapacity(backend); + if (this.canReuseRunBinding(existingLease, request)) { + return this.refreshLease(existingLease, leaseTtlMs); + } + if (existingLease.deviceKey) { + this.throwDeviceBusy(existingLease); + } + this.assertOptionalLeaseIdentityMatch(existingLease, request); + return this.refreshLease(existingLease, leaseTtlMs); + } + + private createLease(request: NormalizedAllocateLeaseRequest, leaseTtlMs: number): DeviceLease { const now = this.now(); - const lease: SimulatorLease = { + return { leaseId: crypto.randomBytes(16).toString('hex'), - tenantId, - runId, - backend, + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + ...(request.leaseProvider ? { leaseProvider: request.leaseProvider } : {}), + ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), + ...(request.clientId ? { clientId: request.clientId } : {}), createdAt: now, heartbeatAt: now, expiresAt: now + leaseTtlMs, }; - this.leases.set(lease.leaseId, lease); - this.runBindings.set(bindingKey, lease.leaseId); - return { ...lease }; } - heartbeatLease(request: HeartbeatLeaseRequest): SimulatorLease { - const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'Invalid lease id.'); - } + heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); - if (!lease) { - throw new AppError('UNAUTHORIZED', 'Lease is not active', { - reason: 'LEASE_NOT_FOUND', - }); - } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + const lease = this.getActiveLease(leaseId); + this.assertRequiredScopeForDeviceAwareLease(lease, request); + this.assertOptionalScopeMatch(lease, request); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } releaseLease(request: ReleaseLeaseRequest): { released: boolean } { - const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'Invalid lease id.'); - } + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.leases.get(leaseId); if (!lease) { return { released: false }; } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + this.assertRequiredScopeForDeviceAwareLease(lease, request); + this.assertOptionalScopeMatch(lease, request); this.leases.delete(leaseId); - this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + this.unbindLease(lease); return { released: true }; } assertLeaseAdmission(request: AdmissionRequest): void { - const backend = normalizeLeaseBackend(request.backend); + const backend = normalizeLeaseBackend(request.leaseBackend); const tenantId = normalizeTenantId(request.tenantId); if (!tenantId) { throw new AppError('INVALID_ARGS', 'tenant isolation requires tenant id.'); @@ -191,31 +322,46 @@ export class LeaseRegistry { throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.'); } this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); - if (!lease) { - throw new AppError('UNAUTHORIZED', 'Lease is not active', { - 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', - }); - } + const lease = this.getActiveLease(leaseId); + this.assertOptionalScopeMatch(lease, { + tenantId, + runId, + leaseBackend: backend, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); } - listActiveLeases(): SimulatorLease[] { + listActiveLeases(): DeviceLease[] { this.cleanupExpiredLeases(); return Array.from(this.leases.values()).map((entry) => ({ ...entry })); } - private cleanupExpiredLeases(): void { + consumeExpiredLeases(): DeviceLease[] { const now = this.now(); + const expired: DeviceLease[] = []; 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); + expired.push({ ...lease }); } + return expired; + } + + consumeExpiredLease(leaseId: string): DeviceLease | undefined { + const normalizedLeaseId = normalizeLeaseId(leaseId); + if (!normalizedLeaseId) return undefined; + const lease = this.leases.get(normalizedLeaseId); + if (!lease || lease.expiresAt > this.now()) return undefined; + this.leases.delete(lease.leaseId); + this.unbindLease(lease); + return { ...lease }; + } + + private cleanupExpiredLeases(): void { + this.consumeExpiredLeases(); } private enforceCapacity(backend: LeaseBackend): void { @@ -246,53 +392,212 @@ export class LeaseRegistry { return value; } - private refreshLease(lease: SimulatorLease, ttlMs: number): SimulatorLease { + private normalizeRequiredLeaseId(raw: string | undefined): string { + const leaseId = normalizeLeaseId(raw); + if (!leaseId) { + throw new AppError('INVALID_ARGS', 'Invalid lease id.'); + } + return leaseId; + } + + private getActiveLease(leaseId: string): DeviceLease { + const lease = this.leases.get(leaseId); + if (lease) return lease; + throw new AppError('UNAUTHORIZED', 'Lease is not active', { + reason: 'LEASE_NOT_FOUND', + }); + } + + 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, + leaseProvider: 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, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + ); + const deviceBindingKey = this.deviceBindingKey(lease); + if (deviceBindingKey) { + this.deviceBindings.delete(deviceBindingKey); + } } - private bindingKey(tenantId: string, runId: string, backend: LeaseBackend): string { - return `${tenantId}:${runId}:${backend}`; + private bindingKey(params: { + tenantId: string; + runId: string; + backend: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + }): string { + return JSON.stringify([ + params.tenantId, + params.runId, + params.backend, + params.leaseProvider ?? DEFAULT_LEASE_PROVIDER, + params.deviceKey ?? '*', + ]); } - private assertOptionalScopeMatch( - lease: SimulatorLease, - tenantRaw: string | undefined, - runRaw: string | undefined, + 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; + leaseProvider?: string; + deviceKey?: string; + }): void { + const deviceBindingKey = this.deviceBindingKey({ + backend: params.backend, + leaseProvider: params.leaseProvider, + 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; + } + this.throwDeviceBusy(activeLease); + } + + private canReuseRunBinding( + lease: DeviceLease, + request: { + clientId?: string; + }, + ): boolean { + return lease.clientId === request.clientId; + } + + private throwDeviceBusy(activeLease: DeviceLease): never { + throw new AppError('COMMAND_FAILED', 'Device is already leased', { + reason: 'DEVICE_LEASE_BUSY', + deviceKey: activeLease.deviceKey, + backend: activeLease.backend, + leaseProvider: activeLease.leaseProvider, + expiresAt: activeLease.expiresAt, + hint: 'Retry after the lease expires or close the owning session.', + }); + } + + private assertRequiredScopeForDeviceAwareLease( + lease: DeviceLease, + request: LeaseScopeMatchRequest, ): void { - const tenantId = normalizeTenantId(tenantRaw); - const runId = normalizeRunId(runRaw); - if (tenantRaw && !tenantId) { + if (!leaseRequiresOwnerScope(lease)) return; + if (!hasRequiredOwnerScope(lease, request)) { + this.throwScopeRequired(); + } + } + + private assertOptionalScopeMatch(lease: DeviceLease, request: LeaseScopeMatchRequest): void { + const normalized = this.normalizeOptionalScopeMatchRequest(request); + if ( + (normalized.tenantId && lease.tenantId !== normalized.tenantId) || + (normalized.runId && lease.runId !== normalized.runId) || + (normalized.leaseBackend && lease.backend !== normalized.leaseBackend) + ) { + this.throwScopeMismatch(); + } + this.assertOptionalLeaseIdentityMatch(lease, normalized); + } + + private normalizeOptionalScopeMatchRequest( + request: LeaseScopeMatchRequest, + ): NormalizedLeaseScopeMatchRequest { + 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', - }); + return { + tenantId, + runId, + leaseBackend: request.leaseBackend ? normalizeLeaseBackend(request.leaseBackend) : undefined, + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + }; + } + + private assertOptionalLeaseIdentityMatch( + lease: DeviceLease, + request: { + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + }, + ): void { + if (request.leaseProvider && lease.leaseProvider !== request.leaseProvider) { + this.throwScopeMismatch(); + } + if (request.deviceKey && lease.deviceKey !== request.deviceKey) { + this.throwScopeMismatch(); } - if (runId && lease.runId !== runId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); + 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', + }); + } + + private throwScopeRequired(): never { + throw new AppError('UNAUTHORIZED', 'Lease owner scope is required', { + reason: 'LEASE_SCOPE_REQUIRED', + }); + } } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 1930a0784..b727dafde 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -1,9 +1,16 @@ import { AppError } from '../utils/errors.ts'; import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isLeaseAdmissionExempt } from './daemon-command-registry.ts'; -import { resolveLeaseScope } from './lease-context.ts'; -import type { LeaseRegistry } from './lease-registry.ts'; -import type { DaemonRequest } from './types.ts'; +import { + DEFAULT_PROXY_LEASE_TTL_MS, + findMissingProxyLeaseFields, + isProxyLeaseScope, + resolveLeaseScope, + resolveRequestOrSessionLeaseScope, +} from './lease-context.ts'; +import { leaseScopeToHeartbeatRequest } from '../core/lease-scope.ts'; +import type { DeviceLease, LeaseRegistry } from './lease-registry.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; export function scopeRequestSession(req: DaemonRequest): DaemonRequest { const isolation = resolveSessionIsolationMode( @@ -52,15 +59,74 @@ export function scopeRequestSession(req: DaemonRequest): DaemonRequest { export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, -): void { - if (isLeaseAdmissionExempt(req.command) || req.meta?.sessionIsolation !== 'tenant') { - return; + session?: SessionState, +): DeviceLease | undefined { + if (isLeaseAdmissionExempt(req.command)) { + return undefined; + } + const requestLeaseScope = resolveLeaseScope(req); + assertProxyOpenLeaseMetadata(req, requestLeaseScope); + const sessionLease = session?.lease; + if (!sessionLease && req.meta?.sessionIsolation !== 'tenant') { + if (!requestLeaseScope.leaseId) return undefined; + if (!requestLeaseScope.tenantId && !requestLeaseScope.runId) return undefined; } - const leaseScope = resolveLeaseScope(req); - leaseRegistry.assertLeaseAdmission({ - tenantId: leaseScope.tenantId, - runId: leaseScope.runId, - leaseId: leaseScope.leaseId, - backend: leaseScope.leaseBackend, + assertRequestSessionLeaseMatches(requestLeaseScope, sessionLease); + const leaseScope = resolveRequestOrSessionLeaseScope(req, session); + const heartbeatLeaseScope = { + ...leaseScope, + leaseTtlMs: + leaseScope.leaseTtlMs ?? + (isProxyLeaseScope(leaseScope) ? DEFAULT_PROXY_LEASE_TTL_MS : undefined), + }; + leaseRegistry.assertLeaseAdmission(leaseScopeToHeartbeatRequest(leaseScope)); + return leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(heartbeatLeaseScope)); +} + +export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { + if (isLeaseAdmissionExempt(req.command)) return; + assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); +} + +function assertProxyOpenLeaseMetadata( + req: DaemonRequest, + requestLeaseScope: ReturnType, +): void { + if (req.command !== 'open') return; + const missing = findMissingProxyLeaseFields(requestLeaseScope); + if (missing.length === 0) return; + throw new AppError( + 'INVALID_ARGS', + 'Proxy open requires leaseId, tenantId, runId, clientId, and deviceKey lease metadata.', + { missing }, + ); +} + +function assertRequestSessionLeaseMatches( + requestLeaseScope: ReturnType, + sessionLease: SessionState['lease'] | undefined, +): void { + if (!sessionLease) return; + assertMatchingLeaseField('leaseId', requestLeaseScope.leaseId, sessionLease.leaseId); + assertMatchingLeaseField('tenantId', requestLeaseScope.tenantId, sessionLease.tenantId); + assertMatchingLeaseField('runId', requestLeaseScope.runId, sessionLease.runId); + assertMatchingLeaseField( + 'leaseProvider', + requestLeaseScope.leaseProvider, + sessionLease.leaseProvider, + ); + assertMatchingLeaseField('clientId', requestLeaseScope.clientId, sessionLease.clientId); + assertMatchingLeaseField('deviceKey', requestLeaseScope.deviceKey, sessionLease.deviceKey); +} + +function assertMatchingLeaseField( + field: string, + requestValue?: string, + sessionValue?: string, +): void { + if (!requestValue || !sessionValue || requestValue === sessionValue) return; + throw new AppError('UNAUTHORIZED', `Lease does not match session owner (${field})`, { + reason: 'LEASE_SESSION_MISMATCH', + field, }); } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 115edb418..a9a4f53b0 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -10,7 +10,12 @@ import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; import { resolveEffectiveSessionName } from './session-routing.ts'; -import { assertRequestLeaseAdmission, scopeRequestSession } from './request-admission.ts'; +import { scopeRequestSession } from './request-admission.ts'; +import { + admitRequestLeaseForLockedScope, + assertLockedLeaseAdmissionPreflight, + cleanupExpiredLeasedSession, +} from './lease-lifecycle.ts'; import { prepareLockedRequestBinding, resolveRequestExecutionLockKeys, @@ -31,6 +36,7 @@ import { type SessionStore, } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { teardownSessionResources } from './session-teardown.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -42,6 +48,7 @@ export type RequestExecutionScope = { sessionName: string; requestLogPath: string; runnerLogPath: string; + runAdmitted(task: () => Promise): Promise; runLocked(task: () => Promise): Promise; throwIfCanceled(): void; }; @@ -74,7 +81,7 @@ export async function createRequestExecutionScope(params: { leaseRegistry: LeaseRegistry; }): Promise { const { sessionStore, leaseRegistry } = params; - const scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); + let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); const command = scopedReq.command; const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); @@ -102,7 +109,7 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - assertRequestLeaseAdmission(scopedReq, leaseRegistry); + assertLockedLeaseAdmissionPreflight(scopedReq); const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -115,13 +122,31 @@ export async function createRequestExecutionScope(params: { requestLogPath, runnerLogPath, throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), - runLocked: async (task) => { + runAdmitted: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); - if (executionLockKeys.length === 0) return await task(); - return await withRequestExecutionLocks(executionLocks, executionLockKeys, async () => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - return await task(); + await cleanupExpiredLeasedSession({ + sessionName, + sessionStore, + leaseRegistry, + teardownSession: teardownSessionResources, + }); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, }); + scope.req = scopedReq; + return await task(); + }, + runLocked: async (task) => { + throwIfRequestCanceled(scopedReq.meta?.requestId); + if (executionLockKeys.length === 0) return await scope.runAdmitted(task); + return await withRequestExecutionLocks( + executionLocks, + executionLockKeys, + async () => await scope.runAdmitted(task), + ); }, }; return scope; @@ -207,7 +232,8 @@ export function prepareLockedRequestScope(params: { flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, - ): DaemonCommandContext => contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath); + ): DaemonCommandContext => + contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath, lockedReq.meta); return { type: 'scope', @@ -233,10 +259,11 @@ function contextFromRequestFlags( flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const requestId = getDiagnosticsMeta().requestId; return { - ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId), + ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId, meta), requestId, }; } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index b7c863305..ebd25e2d9 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -73,6 +73,7 @@ async function runSessionHandler(params: RequestHandlerChainParams): Promise { + await stopIosRunnerSession(session.device.id); + if (session.device.platform !== 'macos') { + return; + } + + const dismissOptions = + session.surface === 'frontmost-app' + ? { surface: 'frontmost-app' as const } + : session.appBundleId + ? { bundleId: session.appBundleId } + : {}; + await runMacOsAlertAction('dismiss', dismissOptions).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'macos_close_alert_dismiss_failed', + data: { + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); +} + +export async function stopSessionAppLog(session: SessionState): Promise { + if (!session.appLog) return; + await stopAppLog(session.appLog); +} + +export async function stopSessionApplePerfCapture(session: SessionState): Promise { + if (!session.applePerf?.active) return; + await cleanupAppleXctracePerfCapture(session.applePerf.active); + session.applePerf = { ...(session.applePerf ?? {}), active: undefined }; +} + +export async function stopSessionAndroidNativePerfCapture(session: SessionState): Promise { + const active = session.nativePerf?.android; + if (!active) return; + await cleanupAndroidNativePerfSession(session.device, active); + session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined }; +} + +export async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise { + if (session.device.platform !== 'android') return; + await stopAndroidSnapshotHelperSessionForDevice(session.device); +} + +export async function teardownSessionResources( + session: SessionState, + sessionName: string, +): Promise { + await stopSessionAppLog(session); + await stopSessionApplePerfCapture(session); + await stopSessionAndroidNativePerfCapture(session); + await stopSessionAndroidSnapshotHelper(session); + if (isApplePlatform(session.device.platform)) { + await stopAppleRunnerForClose(session); + } + await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index e970dabb0..1c8aec0a0 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -19,6 +19,7 @@ import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts' import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; import type { AppLogState } from './app-log-process.ts'; +import type { DeviceLease } from './lease-registry.ts'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; import type { AppleXctracePerfCapture, @@ -46,6 +47,7 @@ export type DaemonOpenLifecycle = { type DaemonRequestInternal = { openLifecycle?: DaemonOpenLifecycle; + admittedLease?: DeviceLease; }; export type DaemonRequest = Omit & { @@ -227,6 +229,16 @@ export type SessionState = { kind: 'cwd'; id: string; }; + lease?: { + leaseId: string; + tenantId: string; + runId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + expiresAt?: number; + }; device: DeviceInfo; createdAt: number; surface?: SessionSurface; diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 86524d179..14e6ff3eb 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -110,6 +110,7 @@ import { cleanupRunnerLeasesForOwner, RUNNER_OWNER_START_TIME, RUNNER_OWNER_TOKEN, + setRunnerLeaseOwnerStateDir, writeRunnerLease, type RunnerLease, } from '../runner-lease.ts'; @@ -117,6 +118,7 @@ import { beforeEach(async () => { await abortAllIosRunnerSessions(); vi.resetAllMocks(); + setRunnerLeaseOwnerStateDir(undefined); process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-runner-lease-test-'), ); @@ -609,6 +611,29 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv }); }); +test('runner session startup diagnostics include logical lease context', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-lease-context-sim' }; + + const diagnostics = await captureDiagnostics(async () => { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + }); + + assert.match(diagnostics, /ios_runner_session_startup/); + assert.match(diagnostics, /"logicalLeaseContext"/); + assert.match(diagnostics, /"tenantId":"tenant-123"/); + assert.match(diagnostics, /"runId":"run-456"/); + assert.match(diagnostics, /"leaseId":"lease-789"/); + assert.match(diagnostics, /"leaseProvider":"ios-simulator"/); + assert.match(diagnostics, /"deviceKey":"runner-session-lease-context-sim"/); +}); + test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => { const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' }; mockDevToolsSecurityDisabled(); @@ -693,6 +718,10 @@ test('runner session startup rejects live foreign runner lease', async () => { String((thrown as { details?: Record }).details?.hint), /Do not run prepare ios-runner/, ); + assert.match( + String((thrown as { details?: Record }).details?.hint), + /PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); assert.equal(mockRunCmdBackground.mock.calls.length, 0); assert.equal( mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), @@ -704,6 +733,82 @@ test('runner session startup rejects live foreign runner lease', async () => { } }); +test('runner session busy error includes logical lease context after admission', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-logical-busy-lease-sim' }; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-logical-live', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', + }), + ); + + let thrown: unknown; + await assert.rejects(async () => { + try { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + } catch (error) { + thrown = error; + throw error; + } + }, /busy after device lease admission/); + + assert.ok(thrown instanceof AppError); + assert.deepEqual(thrown.details?.logicalLeaseContext, { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + deviceKey: device.id, + }); + assert.match(String(thrown.details?.hint), /five-minute inactivity lease expires/); + assert.match( + String(thrown.details?.hint), + /Runner owner: PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); + assert.equal(mockRunCmdBackground.mock.calls.length, 0); +}); + +test('runner session startup reclaims live foreign runner lease after proxy lease admission', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-proxy-takeover-sim' }; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-proxy-live', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', + runnerPid: 4_321, + }), + ); + + const session = await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'proxy', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: `ios:mobile:${device.id}`, + }, + }); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-proxy-live/); +}); + test('runner session startup reclaims live foreign runner lease from same state dir', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' }; const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; @@ -738,6 +843,38 @@ test('runner session startup reclaims live foreign runner lease from same state } }); +test('runner session startup reclaims same-state live lease from daemon runtime owner state dir', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-runtime-state-lease-sim' }; + const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; + const stateDir = '/tmp/agent-device-runtime-state'; + delete process.env.AGENT_DEVICE_STATE_DIR; + setRunnerLeaseOwnerStateDir(stateDir); + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-runtime-state', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: stateDir, + runnerPid: 4_321, + }), + ); + + try { + const session = await ensureRunnerSession(device, {}); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-runtime-state/); + } finally { + setRunnerLeaseOwnerStateDir(undefined); + if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; + else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir; + } +}); + test('runner session startup reclaims dead foreign runner lease before launching', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-dead-lease-sim' }; mockIsProcessAlive.mockImplementation((pid) => pid !== 999_999_999 && pid !== 999_999_998); diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index cf6045a2f..1cd707bea 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -72,6 +72,7 @@ export function iosRunnerOverrides( iosXctestrunFile: ctx.iosXctestrunFile, iosXctestDerivedDataPath: ctx.iosXctestDerivedDataPath, iosXctestEnvDir: ctx.iosXctestEnvDir, + runnerLeaseContext: ctx.runnerLeaseContext, }; return { runnerOpts, diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index 506fcaac8..86e052eb8 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -6,6 +6,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { AppError } from '../../utils/errors.ts'; import { acquireProcessLock } from '../../utils/process-lock.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; const RUNNER_LEASE_SCHEMA_VERSION = 1; const RUNNER_LEASE_LOCK_TIMEOUT_MS = 30_000; @@ -16,6 +17,8 @@ const RUNNER_OWNER_PID = process.pid; export const RUNNER_OWNER_START_TIME = readProcessStartTime(process.pid); export const RUNNER_OWNER_TOKEN = buildRunnerOwnerToken(RUNNER_OWNER_PID, RUNNER_OWNER_START_TIME); +let runnerLeaseOwnerStateDir: string | undefined; + export type RunnerLease = { schemaVersion: 1; deviceId: string; @@ -72,6 +75,10 @@ export function buildRunnerLease(params: { }; } +export function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { + runnerLeaseOwnerStateDir = stateDir?.trim() || undefined; +} + export async function withRunnerLeaseLock(deviceId: string, task: () => Promise): Promise { const release = await acquireProcessLock({ lockDirPath: `${resolveRunnerLeasePath(deviceId)}.lock`, @@ -110,6 +117,7 @@ function classifyRunnerLease(lease: RunnerLease | null): RunnerLeaseState { export async function prepareRunnerLeaseForStartup( deviceId: string, cleanup: RunnerLeaseCleanupAdapter, + logicalLeaseContext?: RunnerLogicalLeaseContext, ): Promise { const state = classifyRunnerLease(readRunnerLease(deviceId)); if (state.type === 'empty') { @@ -121,17 +129,24 @@ export async function prepareRunnerLeaseForStartup( await cleanupLeasedRunnerProcesses(state.lease, 'same-state-dir', cleanup); return; } + if (canLogicalLeaseReclaimRunner(state.lease, logicalLeaseContext)) { + await cleanupLeasedRunnerProcesses(state.lease, 'logical-lease-takeover', cleanup); + return; + } throw new AppError( 'COMMAND_FAILED', - `iOS runner for ${deviceId} is already owned by another agent-device daemon`, + logicalLeaseContext + ? `iOS runner for ${deviceId} is busy after device lease admission` + : `iOS runner for ${deviceId} is already owned by another agent-device daemon`, { deviceId, + logicalLeaseContext, ownerPid: state.lease.ownerPid, ownerStartTime: state.lease.ownerStartTime, ownerStateDir: state.lease.ownerStateDir, ownerToken: state.lease.ownerToken, sessionId: state.lease.sessionId, - hint: buildBusyRunnerLeaseHint(state.lease), + hint: buildBusyRunnerLeaseHint(state.lease, logicalLeaseContext), }, ); } @@ -145,15 +160,50 @@ function isSameStateDirRunnerLease(lease: RunnerLease): boolean { return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir); } +function canLogicalLeaseReclaimRunner( + lease: RunnerLease, + logicalLeaseContext: RunnerLogicalLeaseContext | undefined, +): boolean { + if (!logicalLeaseContext || logicalLeaseContext.leaseProvider !== 'proxy') return false; + if (!logicalLeaseContext.leaseId || !logicalLeaseContext.clientId) return false; + return logicalLeaseContextMatchesDevice(logicalLeaseContext.deviceKey, lease.deviceId); +} + +function logicalLeaseContextMatchesDevice( + logicalDeviceKey: string | undefined, + runnerDeviceId: string, +): boolean { + if (!logicalDeviceKey) return false; + if (logicalDeviceKey === runnerDeviceId) return true; + const [, , canonicalDeviceId] = logicalDeviceKey.split(':', 3); + return canonicalDeviceId === runnerDeviceId; +} + function readCurrentStateDir(): string | undefined { + if (runnerLeaseOwnerStateDir) return runnerLeaseOwnerStateDir; return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } -function buildBusyRunnerLeaseHint(lease: RunnerLease): string { +function buildBusyRunnerLeaseHint( + lease: RunnerLease, + logicalLeaseContext?: RunnerLogicalLeaseContext, +): string { const owner = `PID ${lease.ownerPid}`; const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : ''; + const currentStateDir = readCurrentStateDir(); + const current = + currentStateDir && currentStateDir !== lease.ownerStateDir + ? ` Current daemon state dir is ${currentStateDir}.` + : ''; + if (logicalLeaseContext) { + return [ + `The device is busy because another active device lease owns it, or the runner is owned by another daemon/process after lease admission. Runner owner: ${owner}${stateDir}.${current}`, + 'Retry after the owning session closes or after the five-minute inactivity lease expires.', + 'If this persists after expiry, inspect the runner owner details and clean the stale daemon state on the machine with simulator access.', + ].join(' '); + } return [ - `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`, + `Runner owner details: ${owner}${stateDir}.${current} Retry after the owning runner finishes.`, 'Do not run prepare ios-runner from another daemon/client to recover this; a live foreign runner lease cannot be released by the remote client.', ].join(' '); } @@ -314,11 +364,14 @@ function isRunnerLeaseOwnerAlive(lease: RunnerLease): boolean { async function cleanupLeasedRunnerProcesses( lease: RunnerLease, - reason: 'owned' | 'stale' | 'same-state-dir', + reason: 'owned' | 'stale' | 'same-state-dir' | 'logical-lease-takeover', cleanup: RunnerLeaseCleanupAdapter, ): Promise { emitDiagnostic({ - level: reason === 'stale' || reason === 'same-state-dir' ? 'warn' : 'debug', + level: + reason === 'stale' || reason === 'same-state-dir' || reason === 'logical-lease-takeover' + ? 'warn' + : 'debug', phase: 'ios_runner_lease_cleanup', data: { deviceId: lease.deviceId, diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 277379a68..f14ff8c46 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerCommand } from './runner-contract.ts'; import type { @@ -14,6 +15,7 @@ export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { cleanStaleBundles?: boolean; startupTimeoutMs?: number; requestId?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 3a114b207..f0ceb870f 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -1,3 +1,4 @@ +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; @@ -21,6 +22,7 @@ export type RunnerSession = { lastHealthyMutation?: { atMs: number; appBundleId?: string }; startupTimings?: Record; startupTimingsReported?: boolean; + logicalLeaseContext?: RunnerLogicalLeaseContext; simulatorSetRedirect?: { release: () => Promise }; lease?: RunnerLease; }; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 8b6e3da18..516af68d4 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -3,6 +3,7 @@ import { runCmdBackground, type ExecResult, type ExecBackgroundResult } from '.. import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { Deadline } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; @@ -108,8 +109,20 @@ async function startRunnerSessionWithLease( options: RunnerSessionOptions, ): Promise { const startupTimings: Record = {}; + const logicalLeaseContext = normalizeRunnerLogicalLeaseContext( + options.runnerLeaseContext, + device.id, + ); + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_session_startup', + data: { + deviceId: device.id, + logicalLeaseContext, + }, + }); await measureRunnerStartupStep(startupTimings, 'cleanup_stale_xcodebuild', async () => { - await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter); + await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter, logicalLeaseContext); }); await measureRunnerStartupStep(startupTimings, 'ensure_booted', async () => { await ensureBootedIfNeeded(device); @@ -222,6 +235,7 @@ async function startRunnerSessionWithLease( ready: false, startupTimeoutMs: normalizeRunnerStartupTimeoutMs(options.startupTimeoutMs), startupTimings, + logicalLeaseContext, simulatorSetRedirect: simulatorSetRedirect ?? undefined, lease, }; @@ -262,6 +276,7 @@ async function resolveReusableRunnerSession( sessionId: existing.sessionId, ready: existing.ready, cache: existingArtifact.cache, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -295,6 +310,7 @@ async function resolveReusableRunnerSession( deviceId: device.id, sessionId: existing.sessionId, ready: existing.ready, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -889,7 +905,29 @@ function emitRunnerStartupTimings(session: RunnerSession, command: string): void command, sessionId: session.sessionId, ready: session.ready, + logicalLeaseContext: session.logicalLeaseContext, timings: session.startupTimings, }, }); } + +function normalizeRunnerLogicalLeaseContext( + context: RunnerLogicalLeaseContext | undefined, + deviceKey: string, +): RunnerLogicalLeaseContext | undefined { + if (!context) return undefined; + const normalized = { + leaseId: readOptionalContextString(context.leaseId), + clientId: readOptionalContextString(context.clientId), + tenantId: readOptionalContextString(context.tenantId), + runId: readOptionalContextString(context.runId), + leaseProvider: readOptionalContextString(context.leaseProvider), + deviceKey: readOptionalContextString(context.deviceKey) ?? deviceKey, + }; + const entries = Object.entries(normalized).filter(([, value]) => value !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function readOptionalContextString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} 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..365b42f0b 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -24,7 +24,7 @@ export type RemoteConfigMetroOptions = { metroNoInstallDeps?: boolean; }; -export type RemoteConfigProfile = RemoteConfigMetroOptions & { +export type RemoteConnectionProfileFields = { stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; @@ -35,16 +35,23 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & { runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; - platform?: PlatformSelector; - target?: DeviceTarget; - device?: string; - udid?: string; - serial?: string; - iosSimulatorDeviceSet?: string; - androidDeviceAllowlist?: string; - session?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; +export type RemoteConfigProfile = RemoteConfigMetroOptions & + RemoteConnectionProfileFields & { + platform?: PlatformSelector; + target?: DeviceTarget; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; + session?: string; + }; + export type RemoteConfigProfileOptions = { configPath: string; cwd: string; @@ -109,8 +116,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..52c0761d5 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -6,6 +6,11 @@ import { AppError } from './utils/errors.ts'; import { emitDiagnostic } from './utils/diagnostics.ts'; import type { CliFlags } from './utils/cli-flags.ts'; import type { LeaseBackend, SessionRuntimeHints } from './contracts.ts'; +import { + leaseScopeFromOptions, + leaseScopeToCommandFlags, + leaseScopeToConnectionMetadata, +} from './core/lease-scope.ts'; export type RemoteConnectionState = { version: 1; @@ -14,6 +19,7 @@ export type RemoteConnectionState = { remoteConfigHash: string; daemon?: { baseUrl?: string; + authToken?: string; transport?: CliFlags['daemonTransport']; serverMode?: CliFlags['daemonServerMode']; }; @@ -21,6 +27,9 @@ export type RemoteConnectionState = { runId: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; runtime?: SessionRuntimeHints; @@ -33,9 +42,15 @@ export type RemoteConnectionState = { updatedAt: string; }; +export type RemoteConnectionRequestMetadata = Pick< + RemoteConnectionState, + 'leaseProvider' | 'deviceKey' | 'clientId' +>; + type RemoteConnectionDefaults = { flags: Partial; runtime?: SessionRuntimeHints; + connection?: RemoteConnectionRequestMetadata; }; export function readRemoteConnectionState(options: { @@ -72,10 +87,14 @@ export function writeRemoteConnectionState(options: { } export function buildRemoteConnectionDaemonState( - flags: Pick, + flags: Pick< + CliFlags, + 'daemonBaseUrl' | 'daemonAuthToken' | 'daemonTransport' | 'daemonServerMode' + >, ): RemoteConnectionState['daemon'] { return { baseUrl: sanitizeDaemonBaseUrl(flags.daemonBaseUrl), + authToken: flags.daemonAuthToken, transport: flags.daemonTransport, serverMode: flags.daemonServerMode, }; @@ -127,19 +146,19 @@ export function resolveRemoteConnectionDefaults(options: { ); } const profile = resolveConnectionProfile(state, options); + const leaseScope = leaseScopeFromOptions(state); return { runtime: state.runtime, + connection: leaseScopeToConnectionMetadata(leaseScope), flags: { ...profile, remoteConfig: state.remoteConfigPath, daemonBaseUrl: state.daemon?.baseUrl ?? profile.daemonBaseUrl, + daemonAuthToken: state.daemon?.authToken ?? profile.daemonAuthToken, daemonTransport: state.daemon?.transport ?? profile.daemonTransport, daemonServerMode: state.daemon?.serverMode ?? profile.daemonServerMode, - tenant: state.tenant, + ...leaseScopeToCommandFlags(leaseScope), sessionIsolation: 'tenant', - runId: state.runId, - leaseId: state.leaseId, - leaseBackend: state.leaseBackend, session: state.session, platform: state.platform ?? profile.platform, target: state.target ?? profile.target, @@ -147,6 +166,12 @@ export function resolveRemoteConnectionDefaults(options: { }; } +export function buildRemoteConnectionRequestMetadata( + state: RemoteConnectionState, +): RemoteConnectionRequestMetadata | undefined { + return leaseScopeToConnectionMetadata(leaseScopeFromOptions(state)); +} + export function hashRemoteConfigFile(configPath: string): string { try { return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); @@ -269,18 +294,46 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState const record = value as Record; return ( record.version === 1 && - typeof record.session === 'string' && - typeof record.remoteConfigPath === 'string' && - typeof record.remoteConfigHash === 'string' && - (record.daemon === undefined || - (typeof record.daemon === 'object' && - record.daemon !== null && - !Array.isArray(record.daemon))) && - typeof record.tenant === 'string' && - typeof record.runId === 'string' && - (record.leaseId === undefined || typeof record.leaseId === 'string') && - (record.leaseBackend === undefined || typeof record.leaseBackend === 'string') && - typeof record.connectedAt === 'string' && - typeof record.updatedAt === 'string' + hasStringFields(record, [ + 'session', + 'remoteConfigPath', + 'remoteConfigHash', + 'tenant', + 'runId', + 'connectedAt', + 'updatedAt', + ]) && + hasOptionalStringFields(record, [ + 'leaseId', + 'leaseBackend', + 'leaseProvider', + 'deviceKey', + 'clientId', + ]) && + isOptionalRemoteConnectionDaemonState(record.daemon) + ); +} + +function hasStringFields(record: Record, fields: string[]): boolean { + return fields.every((field) => typeof record[field] === 'string'); +} + +function hasOptionalStringFields(record: Record, fields: string[]): boolean { + return fields.every((field) => record[field] === undefined || typeof record[field] === 'string'); +} + +function isOptionalRemoteConnectionDaemonState(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return isRemoteConnectionDaemonState(value); +} + +function isRemoteConnectionDaemonState(value: object): boolean { + const record = value as Record; + return ( + (record.baseUrl === undefined || typeof record.baseUrl === 'string') && + (record.authToken === undefined || typeof record.authToken === 'string') && + (record.transport === undefined || typeof record.transport === 'string') && + (record.serverMode === undefined || typeof record.serverMode === 'string') ); } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 95349d3ce..b89d30b8e 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -700,6 +700,16 @@ test('parseArgs recognizes connect lease backend force and no-login flags', () = assert.equal(parsed.flags.noLogin, true); }); +test('parseArgs preserves connect proxy provider positional', () => { + const parsed = parseArgs( + ['connect', 'proxy', '--daemon-base-url', 'http://host:4310/agent-device'], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['proxy']); + assert.equal(parsed.flags.daemonBaseUrl, 'http://host:4310/agent-device'); +}); + test('parseArgs accepts auth management subcommands', () => { const status = parseArgs(['auth', 'status'], { strictFlags: true }); assert.equal(status.command, 'auth'); @@ -1232,12 +1242,13 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /verify the action with diff snapshot -i or snapshot --diff/); assert.match(usageText, /Sparse or AX-unavailable snapshot/); assert.match(usageText, /macOS context menus use click --button secondary/); - assert.match(usageText, /Direct proxy: Cloud\/Linux clients can use iOS simulators/); - assert.match(usageText, /A proxy URL\/token means direct proxy mode/); - assert.match(usageText, /Direct proxy sessions: choose one explicit --session/); - assert.match(usageText, /do not use connect, --remote-config, tenant, run, or lease flags/); - assert.match(usageText, /Cloud\/remote-config profiles are separate from direct proxy/); - assert.match(usageText, /Do not substitute --config/); + assert.match( + usageText, + /Remote lifecycle: use connect, then open, commands, close, and disconnect/, + ); + assert.match(usageText, /connect proxy --daemon-base-url /); + assert.match(usageText, /Device leases are automatic on open/); + assert.match(usageText, /expire after five minutes of inactivity/); assert.match(usageText, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( @@ -1575,11 +1586,10 @@ test('usageForCommand resolves remote help topic', () => { const help = usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); assert.match(help, /agent-device connect/); - assert.match(help, /There are two different remote modes/); - assert.match(help, /Direct proxy: agent-device proxy exposes a Mac you control/); - assert.match(help, /A cloud\/Linux client can use iOS simulators through that proxied Mac/); - assert.match(help, /Use one explicit --session across open, snapshot, interactions, and close/); - assert.match(help, /Do not use connect, --remote-config, tenant, run, or lease flags/); + assert.match(help, /Remote connection providers use the same lifecycle/); + assert.match(help, /connect -> open -> commands -> close -> disconnect/); + assert.match(help, /Direct proxy: agent-device connect proxy/); + assert.match(help, /stores the shared proxy profile and client identity/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /Script flow, per-command config/); @@ -1587,17 +1597,18 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device proxy --port 4310/); assert.match( help, - /--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, - ); - assert.match(help, /agent-device open Maps --session maps/); - assert.match(help, /agent-device snapshot -i --session maps/); - assert.match(help, /agent-device close --session maps/); - assert.match(help, /store daemonBaseUrl and daemonAuthToken in normal agent-device\.json/); - assert.match(help, /keep the same explicit --session until close/); - assert.match(help, /do not run prepare ios-runner from the remote client/); - assert.match(help, /same-proxy-state stale runner leases are reclaimed/); + /connect proxy --daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, + ); + assert.match(help, /agent-device open Maps --platform ios/); + assert.match(help, /agent-device snapshot -i --platform ios/); + assert.match(help, /agent-device close/); + assert.match(help, /Device leases are acquired on open/); + assert.match(help, /expire after five minutes without commands/); + assert.match(help, /Multiple agents can share one proxy/); + assert.match(help, /disconnect releases local connection state/); + assert.match(help, /A busy direct-proxy device error means another agent owns the device/); + assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); - assert.match(help, /do not use agent-device auth, connect, disconnect, --remote-config/); assert.match(help, /Do not use --config as a remote profile flag/); assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 87d327b7f..4db492e5c 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -81,7 +81,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { listUsageOverride: 'proxy', helpDescription: `Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy. -Run this on the host that has access to simulators/devices, then point another machine at the printed daemon base URL with --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL. +Run this on the host that has access to simulators/devices, expose the printed local proxy URL through a tunnel, then point another machine at the tunnel URL with connect proxy. The proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload, and /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token. @@ -90,7 +90,7 @@ Use the /agent-device base path when connecting through cloudflared, ngrok, or a Examples: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 - agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, + agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, summary: 'Expose a local daemon through cloudflared, ngrok, or another HTTP tunnel', allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'], }, diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 6279d60f6..b9aac2ed0 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -80,9 +80,8 @@ const AGENT_QUICKSTART_LINES = [ 'Raw coordinates are fallback-only: use snapshot -i --json rects when iOS refs no-op or child refs are missing, then verify the action with diff snapshot -i or snapshot --diff.', 'Sparse or AX-unavailable snapshot: use screenshot for visual truth, press the visible coordinate to leave the bad screen, then retry AX with snapshot -i.', 'macOS context menus use click --button secondary, then snapshot -i. Longpress is for mobile hold gestures, not macOS secondary-click menus.', - 'Direct proxy: Cloud/Linux clients can use iOS simulators through a Mac running agent-device proxy. A proxy URL/token means direct proxy mode: use --daemon-base-url plus --daemon-auth-token, or saved daemonBaseUrl/daemonAuthToken config.', - 'Direct proxy sessions: choose one explicit --session and reuse it for open/snapshot/interactions/close; do not use connect, --remote-config, tenant, run, or lease flags.', - 'Cloud/remote-config profiles are separate from direct proxy: use connect or --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', + 'Remote lifecycle: use connect, then open, commands, close, and disconnect. Cloud, remote-config, direct proxy, and limrun use the same flow.', + 'Direct proxy: run agent-device connect proxy --daemon-base-url before using a shared Mac proxy. Device leases are automatic on open and expire after five minutes of inactivity.', 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', '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.', @@ -268,8 +267,9 @@ Validation and evidence: Android animations: settings animations off/on, not animations disable/restore. Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. Network headers: network dump --include headers; do not write network log headers. - Direct proxy to a Mac you control: cloud/Linux clients can still use iOS simulators through the proxied Mac. Use the printed /agent-device daemon base URL and auth token, or store them as daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close. Do not use connect, --remote-config, tenant, run, or lease flags for direct proxy simulators. - Cloud/remote-config profiles: use connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. + Remote lifecycle: cloud, remote-config, direct proxy, and limrun use the same flow: connect, open, commands, close, disconnect. + Remote config profile: agent-device connect --remote-config ./remote-config.json; then run normal commands and disconnect. + Direct proxy to a Mac you control: cloud/Linux clients can use local/proxy iOS devices through the proxied Mac. Run agent-device connect proxy --daemon-base-url first. Device leases are automatic on open and expire after five minutes of inactivity. Web: agent-device uses a managed, pinned agent-browser backend as an implementation detail. Use --platform web when a browser step belongs inside an agent-device session, replay, batch, MCP, or typed-client flow; use agent-browser directly for standalone web automation. Run agent-device web setup before first use, then agent-device web doctor for backend health checks. Web automation requires Node 24+. agent-device web setup agent-device web doctor @@ -647,19 +647,25 @@ Android physical-device prerequisites: summary: 'Direct proxy, cloud profiles, and remote config', body: `agent-device help remote -There are two different remote modes: - 1. Direct proxy: agent-device proxy exposes a Mac you control. A cloud/Linux client can use iOS simulators through that proxied Mac. Use --daemon-base-url plus --daemon-auth-token, or store daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close so implicit cwd-scoped default sessions do not diverge. Do not use connect, --remote-config, tenant, run, or lease flags for this mode. - 2. Cloud/profile: the cloud connection profile or a local --remote-config owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. +Remote connection providers use the same lifecycle: + connect -> open -> commands -> close -> disconnect + +Providers: + Cloud: agent-device connect discovers the cloud profile. + Remote config: agent-device connect --remote-config ./remote-config.json uses a local profile. + Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 On the remote client: - agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device open Maps --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device snapshot -i --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device close --session maps --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device devices --platform ios + agent-device open Maps --platform ios --device "iPhone 17 Pro" + agent-device snapshot -i --platform ios --device "iPhone 17 Pro" + agent-device close + agent-device disconnect Cloud profile flow: agent-device connect @@ -681,11 +687,14 @@ Script flow, per-command config: Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. Use connect without --remote-config when the cloud control plane owns the connection profile. - Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. - Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token; do not use agent-device auth, connect, disconnect, --remote-config, tenant, run, or lease flags for this direct proxy flow. - For repeated direct proxy commands, store daemonBaseUrl and daemonAuthToken in normal agent-device.json CLI config. Keep platform selection on each command or workflow, and keep the same explicit --session until close. + Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. + Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. + connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. + Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. + disconnect releases local connection state; close releases the active session and device lease. + A busy direct-proxy device error means another agent owns the device until it closes or its inactivity lease expires. Keep the proxy token secret. Anyone with the token can control the proxied daemon. - If iOS snapshot/interaction reports that the runner is already owned by another agent-device daemon, do not run prepare ios-runner from the remote client. Retry the original snapshot or interaction; same-proxy-state stale runner leases are reclaimed by the proxy daemon. If the conflict repeats, the Mac operator should close the owning session or clean the conflicting local daemon. + If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, retry after the owning session closes or after lease expiry. If the conflict repeats, clean stale daemon state on the machine with simulator access. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a2f7de99b..11dfa4aa4 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -17,7 +17,7 @@ On the Mac with simulator or device access: agent-device proxy --port 4310 ``` -The command prints a `daemon base URL` and `daemon auth token`. Keep the token secret; anyone with it can control the proxied daemon. +The command prints the local proxy URL and a `daemon auth token`. Keep the token secret; anyone with it can control the proxied daemon. Expose the proxy with your tunnel: @@ -31,41 +31,22 @@ By default the proxy binds `127.0.0.1`. Use `--host 0.0.0.0` only when you inten ## Remote Client -On the machine running the agent, use the public tunnel origin with the `/agent-device` base path: +On the machine running the agent, connect to the public tunnel origin with the `/agent-device` base path and the printed token: ```bash -export AGENT_DEVICE_DAEMON_BASE_URL="https://example.trycloudflare.com/agent-device" -export AGENT_DEVICE_DAEMON_AUTH_TOKEN="" - +agent-device connect proxy \ + --daemon-base-url https://example.trycloudflare.com/agent-device \ + --daemon-auth-token agent-device devices --platform ios agent-device open MyApp --platform ios agent-device snapshot --platform ios +agent-device close +agent-device disconnect ``` -You can also pass the values per command: - -```bash -agent-device devices \ - --daemon-base-url https://example.trycloudflare.com/agent-device \ - --daemon-auth-token -``` - -For repeated use, put the remote client settings in normal CLI config: - -```json -{ - "daemonBaseUrl": "https://example.trycloudflare.com/agent-device", - "daemonAuthToken": "" -} -``` +`connect proxy` stores the proxy profile and client identity. Device leases are automatic on `open` and expire after five minutes without commands. `close` releases the active session and device lease; `disconnect` clears local connection state. -With `agent-device.json` in the working directory, normal commands pick up those defaults: - -```bash -agent-device devices -agent-device open MyApp -agent-device snapshot -``` +Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or its inactivity lease expires. Do not commit a config file that contains a live `daemonAuthToken`. @@ -81,4 +62,4 @@ Remote clients read `/health` before issuing commands and compare the daemon RPC ## Cleanup -Stop the tunnel and the `agent-device proxy` process when the remote session is done. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly. +Run `agent-device disconnect` when the remote session is done. Stop the tunnel and the `agent-device proxy` process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly.