From 04f53bbc04a6e6379e9d845e39b336e6601fa860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 07:59:47 +0200 Subject: [PATCH 1/3] docs: clarify agent-device help entrypoint --- src/utils/__tests__/args.test.ts | 47 +++++++++++++++++++++++---- src/utils/cli-help.ts | 56 ++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..95349d3ce 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1177,10 +1177,35 @@ test('usage includes only global flags in the top-level global flags section', ( test('usage includes agent workflows, config, environment, and examples footers', () => { const usageText = usage(); + assert.match( + usageText, + /CLI to automate supported app, device, desktop, and web targets for AI agents/, + ); assert.ok( usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'), 'Agent workflows should appear before the command list for agents that only read the top of help.', ); + assert.ok( + usageText.indexOf('Agent Starting Point:') < usageText.indexOf('Agent Workflows:'), + 'The agent starting point should appear before topic selection.', + ); + assert.match(usageText, /Agent Starting Point:/); + assert.match( + usageText, + /agent-device is the default automation surface for app\/device workflows across supported targets/, + ); + assert.match( + usageText, + /Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network\/perf evidence, and verification/, + ); + assert.match( + usageText, + /Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step/, + ); + assert.match( + usageText, + /Start with agent-device help workflow to understand the core loop and how to use the tool/, + ); assert.match(usageText, /Agent Quickstart:/); assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/); assert.match(usageText, /Use selectors or refs as positional targets/); @@ -1225,21 +1250,30 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /Full operating guide: agent-device help workflow/); assert.match(usageText, /Exploratory QA: agent-device help dogfood/); assert.match(usageText, /Agent Workflows:/); - assert.match(usageText, /help workflow\s+Normal bootstrap, exploration, and validation loop/); - assert.match(usageText, /help debugging\s+Logs, network, perf memory, and traces/); assert.match( usageText, - /help react-devtools\s+React Native performance, profiling, component tree, and renders/, + /agent-device help workflow\s+Start here for the core loop, command shape, refs\/selectors, and verification/, + ); + assert.match( + usageText, + /agent-device help debugging\s+Use when logs, network, perf memory, traces, alerts, or diagnostics matter/, + ); + assert.match( + usageText, + /agent-device help react-devtools\s+Use when inspecting components, props\/state\/hooks, renders, or profiles/, + ); + assert.match( + usageText, + /agent-device help physical-device\s+Use when using a connected phone\/tablet or iOS signing setup/, ); assert.match( usageText, - /help physical-device\s+Connected phone\/tablet setup and iOS signing prerequisites/, + /agent-device help react-native\s+Use when the target app is React Native, Expo, or a dev client/, ); assert.match( usageText, - /help react-native\s+React Native app automation hazards, overlays, Metro, and routing/, + /agent-device help web\s+Use when automating a browser through agent-device sessions/, ); - assert.match(usageText, /help web\s+Minimal browser sessions through agent-browser/); assert.match(usageText, /Configuration:/); assert.match( usageText, @@ -1663,6 +1697,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /For app\/package launches, run metro prepare/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..7b5117d63 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -10,31 +10,50 @@ import { } from './command-schema.ts'; const AGENT_WORKFLOWS = [ - { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, perf memory, and traces' }, { - label: 'help react-native', - description: 'React Native app automation hazards, overlays, Metro, and routing', + label: 'agent-device help workflow', + description: 'Start here for the core loop, command shape, refs/selectors, and verification', }, { - label: 'help react-devtools', - description: 'React Native performance, profiling, component tree, and renders', + label: 'agent-device help debugging', + description: 'Use when logs, network, perf memory, traces, alerts, or diagnostics matter', }, { - label: 'help cdp', - description: 'React Native CDP targets, JS heap snapshots, and leak triage', + label: 'agent-device help react-native', + description: 'Use when the target app is React Native, Expo, or a dev client', }, { - label: 'help physical-device', - description: 'Connected phone/tablet setup and iOS signing prerequisites', + label: 'agent-device help react-devtools', + description: 'Use when inspecting components, props/state/hooks, renders, or profiles', }, { - label: 'help remote', - description: 'Remote/cloud config, tenants, leases, and local service tunnels', + label: 'agent-device help cdp', + description: 'Use when investigating JS heap growth, heap snapshots, or retainers', }, - { label: 'help web', description: 'Minimal browser sessions through agent-browser' }, - { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, - { label: 'help dogfood', description: 'Exploratory QA report workflow' }, + { + label: 'agent-device help physical-device', + description: 'Use when using a connected phone/tablet or iOS signing setup', + }, + { + label: 'agent-device help remote', + description: 'Use when working through cloud config, tenants, leases, or local tunnels', + }, + { + label: 'agent-device help web', + description: 'Use when automating a browser through agent-device sessions', + }, + { + label: 'agent-device help macos', + description: 'Use when targeting desktop, frontmost app, or menu bar surfaces', + }, + { label: 'agent-device help dogfood', description: 'Use when producing exploratory QA evidence' }, +] as const; + +const AGENT_START_LINES = [ + 'agent-device is the default automation surface for app/device workflows across supported targets.', + 'Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network/perf evidence, and verification.', + 'Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step.', + 'Start with agent-device help workflow to understand the core loop and how to use the tool.', ] as const; const AGENT_QUICKSTART_LINES = [ @@ -536,7 +555,7 @@ React Native dev loop: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach local Metro. Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. Multiple local worktrees can reuse one native iOS simulator build by running each worktree's Metro on a different port and opening the same installed app on different simulators with explicit runtime hints: @@ -863,7 +882,7 @@ function buildCommandListUsage(commandName: string, schema: CommandSchema): stri function renderUsageText(): string { const header = `agent-device [args] [--json] -CLI to control iOS and Android devices for AI agents. +CLI to automate supported app, device, desktop, and web targets for AI agents. `; const commands = listCliCommandNames().map((name) => { @@ -878,6 +897,7 @@ CLI to control iOS and Android devices for AI agents. const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); const flagsSection = renderFlagSection('Global Flags:', helpFlags); + const startSection = renderTextSection('Agent Starting Point:', AGENT_START_LINES); const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); @@ -885,6 +905,8 @@ CLI to control iOS and Android devices for AI agents. const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); return `${header} +${startSection} + ${workflowsSection} ${commandLines} From c593ecb04d1a8c835b9e096c8a71c3a1f73dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:10:03 +0200 Subject: [PATCH 2/3] docs: trim duplicated help guidance --- src/utils/cli-help.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 7b5117d63..3898d0e7a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -88,7 +88,6 @@ const AGENT_QUICKSTART_LINES = [ 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', 'Debug evidence: Session state contains request diagnostics and runner.log; use logs clear --restart/mark/path, trace, and network dump --include headers for app evidence.', - 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', ] as const; From 228a0977008ee94d6bcce657cb4eab93cdc74ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:52:10 +0200 Subject: [PATCH 3/3] feat: add proxy connect provider --- src/__tests__/remote-connection.test.ts | 281 ++++++++++++++++- src/cli.ts | 31 +- src/cli/cloud-connection-profile.ts | 72 +---- src/cli/commands/connection-runtime.ts | 155 ++++++++- src/cli/commands/connection.ts | 76 ++++- src/cli/generated-remote-config.ts | 74 +++++ src/cli/proxy-connection-profile.ts | 88 ++++++ src/client-normalizers.ts | 3 + src/client-types.ts | 21 ++ src/client.ts | 4 + src/contracts.ts | 56 ++++ src/daemon-client-rpc.ts | 4 + src/daemon-runtime.ts | 5 + .../__tests__/request-router-open.test.ts | 50 ++- src/daemon/handlers/lease.ts | 11 + src/daemon/http-server.ts | 4 + src/daemon/lease-context.ts | 87 ++++++ src/daemon/lease-registry.ts | 293 +++++++++++++++--- src/daemon/request-admission.ts | 3 + .../ios/__tests__/runner-session.test.ts | 34 ++ src/platforms/ios/runner-lease.ts | 14 +- src/remote-config-core.ts | 6 +- src/remote-config-schema.ts | 16 +- src/remote-connection-state.ts | 28 ++ src/utils/__tests__/args.test.ts | 10 + 25 files changed, 1290 insertions(+), 136 deletions(-) create mode 100644 src/cli/generated-remote-config.ts create mode 100644 src/cli/proxy-connection-profile.ts diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 91816e663..f3d04101b 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -20,6 +20,7 @@ import { 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 +75,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 +199,88 @@ 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', + 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.equal(state.daemon?.baseUrl, 'http://proxy.example.test/agent-device'); + 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.leaseProvider, 'proxy'); + assert.equal(generated.leaseTtlMs, undefined); + assert.equal(JSON.stringify(generated).includes('proxy-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 +498,134 @@ test('deferred materialization allocates lease and prepares Metro for open', asy fs.rmSync(tempRoot, { recursive: true, force: true }); }); +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, + provider: 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.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'); @@ -1331,6 +1558,58 @@ 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', + 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(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 13307a124..89075725c 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'; @@ -231,9 +234,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, @@ -247,6 +252,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, @@ -273,7 +281,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, }, @@ -288,6 +296,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }); effectiveFlags = materialized.flags; resolvedRuntime = materialized.runtime; + connectionMetadata = materialized.connection; } if ( shouldWarnOpenMayMissRemoteRuntime({ @@ -307,13 +316,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.'); @@ -456,6 +468,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..80fe9be2f 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -1,14 +1,14 @@ -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 type { RemoteConfigProfile } from '../remote-config-schema.ts'; import { profileToCliFlags } from '../utils/remote-config.ts'; -import { AppError, asAppError } from '../utils/errors.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 { + resolveGeneratedRemoteConfigProfile, + writeGeneratedRemoteConfig, +} from './generated-remote-config.ts'; const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; const HTTP_TIMEOUT_MS = 15_000; @@ -42,12 +42,14 @@ export async function resolveCloudConnectProfile(options: { }); const remoteConfigPath = writeGeneratedRemoteConfig({ stateDir: options.stateDir, + provider: 'cloud', profile, }); const remoteConfig = resolveGeneratedRemoteConfigProfile({ configPath: remoteConfigPath, cwd: options.cwd, env: options.env, + provider: 'Cloud', }); return { flags: { @@ -107,61 +109,3 @@ 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: { - stateDir: string; - profile: RemoteConfigProfile; -}): 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; -} diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 285a382dc..07f3651e8 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -2,13 +2,16 @@ 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 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'; @@ -27,6 +30,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; @@ -35,7 +39,11 @@ export async function materializeRemoteConnectionForCommand(options: { runtime?: SessionRuntimeHints; 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 }; @@ -70,7 +78,12 @@ 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; @@ -78,19 +91,41 @@ export async function materializeRemoteConnectionForCommand(options: { let metroCleanupToStop: RemoteConnectionState['metro'] | undefined; let preparedMetroCleanupOnFailure: RemoteConnectionState['metro'] | undefined; - if (shouldAllocateLeaseForCommand(command)) { - const leaseBackend = state.leaseBackend ?? requireRequestedLeaseBackend(flags, command); - assertRequestedConnectionScope(state, flags, leaseBackend); + if (shouldAllocateLeaseForCommand(command, nextState)) { + const preliminaryLeaseBackend = state.leaseBackend ?? resolveRequestedLeaseBackend(nextFlags); + if (nextState.leaseProvider === 'proxy') { + nextState = ( + await resolveProxyLeaseState({ + command, + client, + state: nextState, + flags: nextFlags, + leaseBackend: preliminaryLeaseBackend, + }) + ).state; + } + const leaseBackend = + nextState.leaseBackend ?? + preliminaryLeaseBackend ?? + requireRequestedLeaseBackend(nextFlags, command); + assertRequestedConnectionScope(state, nextFlags, 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) { + if ( + nextState.leaseId !== lease.leaseId || + nextState.leaseBackend !== leaseBackend || + nextState.deviceKey !== (lease.deviceKey ?? nextState.deviceKey) + ) { nextState = { ...nextState, leaseId: lease.leaseId, leaseBackend, + leaseProvider: lease.leaseProvider ?? lease.provider ?? nextState.leaseProvider, + clientId: lease.clientId ?? nextState.clientId, + deviceKey: lease.deviceKey ?? nextState.deviceKey, platform: nextState.platform ?? flags.platform, target: nextState.target ?? flags.target, updatedAt: new Date().toISOString(), @@ -166,6 +201,7 @@ export async function materializeRemoteConnectionForCommand(options: { target: nextState.target ?? nextFlags.target, }, runtime: nextRuntime, + connection: buildRemoteConnectionRequestMetadata(nextState), }; } @@ -265,6 +301,9 @@ export async function releasePreviousLease( daemonBaseUrl: previous.daemon?.baseUrl, daemonTransport: previous.daemon?.transport, daemonServerMode: previous.daemon?.serverMode, + leaseProvider: previous.leaseProvider, + clientId: previous.clientId, + deviceKey: previous.deviceKey, }); } catch { // Reconnect must succeed even if the old lease was already released. @@ -287,7 +326,8 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa ); } -function shouldAllocateLeaseForCommand(command: string): boolean { +function shouldAllocateLeaseForCommand(command: string, state: RemoteConnectionState): boolean { + if (state.leaseProvider === 'proxy' && command === 'devices') return false; return !leaseDeferredCommands.has(command); } @@ -349,6 +389,7 @@ function selectCompatibleRuntime( function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, + profile: Pick = {}, ): RemoteConnectionState { if (!flags.tenant) { throw new AppError( @@ -379,6 +420,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, @@ -396,6 +440,10 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: leaseTtlMsForConnection(state), }); if (existing) return existing; } @@ -403,9 +451,84 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, + ttlMs: leaseTtlMsForConnection(state), }); } +async function resolveProxyLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; +}): Promise<{ state: RemoteConnectionState }> { + 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(), + }, + }; +} + +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( state: RemoteConnectionState, flags: CliFlags, @@ -437,7 +560,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({ @@ -445,6 +576,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; @@ -452,6 +587,10 @@ async function heartbeatOrAllocateLease( } } +function leaseTtlMsForConnection(state: RemoteConnectionState): number | undefined { + return state.leaseProvider === 'proxy' ? PROXY_REMOTE_LEASE_TTL_MS : undefined; +} + function isInactiveLeaseError(error: unknown): boolean { if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; return ( diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index cae7a593d..09a094081 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -10,9 +10,11 @@ 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, releasePreviousLease, @@ -25,17 +27,32 @@ 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 provider = readConnectProvider(positionals); + if (provider && flags.remoteConfig) { + throw new AppError( + 'INVALID_ARGS', + 'connect provider positional and --remote-config are mutually exclusive.', + ); + } const resolved = flags.remoteConfig ? resolveRemoteConnectFlags(flags) - : await resolveCloudConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }); + : provider === 'proxy' + ? resolveProxyConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }) + : await resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); const connectFlags = resolved.flags; + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); const tenant = connectFlags.tenant; const runId = connectFlags.runId; if (!tenant) { @@ -73,6 +90,7 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => remoteConfigPath: resolved.remoteConfigPath, remoteConfigHash, desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), + connection: connectionMetadata, daemon, }) ) { @@ -99,6 +117,13 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => previous && !connectFlags.force ? previous.leaseBackend : resolveRequestedLeaseBackend(connectFlags), + leaseProvider: + connectionMetadata?.leaseProvider ?? + (previous && !connectFlags.force ? previous.leaseProvider : undefined), + clientId: + connectionMetadata?.clientId ?? + (previous && !connectFlags.force ? previous.clientId : undefined), + deviceKey: previous && !connectFlags.force ? previous.deviceKey : connectionMetadata?.deviceKey, platform: connectFlags.platform ?? (previous && !connectFlags.force ? previous.platform : undefined), target: connectFlags.target ?? (previous && !connectFlags.force ? previous.target : undefined), @@ -148,6 +173,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) { @@ -170,6 +211,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) tenant: state.tenant, runId: state.runId, leaseId: state.leaseId, + leaseProvider: state.leaseProvider, + clientId: state.clientId, + deviceKey: state.deviceKey, }); released = result.released; } catch { @@ -221,6 +265,19 @@ 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 readRequestedConnectionState(flags: CliFlags): { session: string; stateDir: string; @@ -253,6 +310,7 @@ function isCompatibleConnection( remoteConfigPath: string; remoteConfigHash: string; desiredLeaseBackend?: LeaseBackend; + connection?: RemoteConnectionRequestMetadata; daemon: RemoteConnectionState['daemon']; }, ): boolean { @@ -266,6 +324,10 @@ function isCompatibleConnection( state.leaseBackend === options.desiredLeaseBackend) && (options.flags.platform === undefined || state.platform === options.flags.platform) && (options.flags.target === undefined || state.target === options.flags.target) && + (options.connection?.leaseProvider === undefined || + state.leaseProvider === options.connection.leaseProvider) && + (options.connection?.clientId === undefined || + state.clientId === options.connection.clientId) && isSameDaemonState(state.daemon, options.daemon) ); } diff --git a/src/cli/generated-remote-config.ts b/src/cli/generated-remote-config.ts new file mode 100644 index 000000000..4ca5f990a --- /dev/null +++ b/src/cli/generated-remote-config.ts @@ -0,0 +1,74 @@ +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'; + +export function writeGeneratedRemoteConfig(options: { + stateDir: string; + provider: string; + profile: RemoteConfigProfile; +}): 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, + `${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; +} + +export 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, + ); + } +} + +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'; +} diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts new file mode 100644 index 000000000..2a74608bc --- /dev/null +++ b/src/cli/proxy-connection-profile.ts @@ -0,0 +1,88 @@ +import crypto from 'node:crypto'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { profileToCliFlags } from '../utils/remote-config.ts'; +import { AppError } from '../utils/errors.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import type { EnvMap } from '../utils/env-map.ts'; +import { + resolveGeneratedRemoteConfigProfile, + writeGeneratedRemoteConfig, +} 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); + 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, + metroBearerToken: options.flags.metroBearerToken, + 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, + }; + const remoteConfigPath = writeGeneratedRemoteConfig({ + stateDir: options.stateDir, + provider: 'proxy', + profile, + }); + const remoteConfig = resolveGeneratedRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: options.cwd, + env: options.env, + provider: 'Proxy', + }); + return { + flags: { + ...profileToCliFlags(remoteConfig.profile), + ...options.flags, + remoteConfig: remoteConfig.resolvedPath, + daemonBaseUrl, + daemonTransport: options.flags.daemonTransport ?? 'http', + }, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + +function buildProxyClientId(stateDir: string, daemonBaseUrl: string): string { + return crypto + .createHash('sha256') + .update(`${stateDir}\0${daemonBaseUrl}`) + .digest('hex') + .slice(0, 16); +} diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 0424edcb0..d5d8f0818 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -354,6 +354,9 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' leaseId: options.leaseId, leaseBackend: options.leaseBackend, leaseTtlMs: options.leaseTtlMs, + leaseProvider: options.leaseProvider, + clientId: options.clientId, + deviceKey: options.deviceKey, sessionIsolation: options.sessionIsolation, installSource: options.installSource, retainMaterializedPaths: options.retainMaterializedPaths, diff --git a/src/client-types.ts b/src/client-types.ts index 4885e47f6..8dc94c39f 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -72,6 +72,10 @@ export type AgentDeviceClientConfig = { runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + leaseTtlMs?: number; runtime?: SessionRuntimeHints; cwd?: string; debug?: boolean; @@ -95,6 +99,10 @@ export type AgentDeviceRequestOverrides = Pick< | 'runId' | 'leaseId' | 'leaseBackend' + | 'leaseProvider' + | 'deviceKey' + | 'clientId' + | 'leaseTtlMs' | 'cwd' | 'debug' | 'iosXctestrunFile' @@ -274,6 +282,10 @@ export type Lease = { tenantId: string; runId: string; backend: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; createdAt?: number; heartbeatAt?: number; expiresAt?: number; @@ -287,12 +299,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 efca6d455..269585ad7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -396,6 +396,10 @@ function normalizeLease(data: Record): Lease { tenantId: readRequiredString(lease, 'tenantId'), runId: readRequiredString(lease, 'runId'), backend: readRequiredString(lease, 'backend') as Lease['backend'], + leaseProvider: readOptionalString(lease, 'leaseProvider'), + provider: readOptionalString(lease, 'provider') as Lease['provider'], + clientId: readOptionalString(lease, 'clientId'), + deviceKey: readOptionalString(lease, 'deviceKey'), createdAt: typeof lease.createdAt === 'number' ? lease.createdAt : undefined, heartbeatAt: typeof lease.heartbeatAt === 'number' ? lease.heartbeatAt : undefined, expiresAt: typeof lease.expiresAt === 'number' ? lease.expiresAt : undefined, diff --git a/src/contracts.ts b/src/contracts.ts index c2a3a514c..cef68f9a1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -68,6 +68,9 @@ export type DaemonRequestMeta = { leaseId?: string; leaseTtlMs?: number; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; sessionIsolation?: SessionIsolationMode; uploadedArtifactId?: string; clientArtifactPaths?: Record; @@ -129,6 +132,9 @@ export type LeaseAllocatePayload = { runId?: string; ttlMs?: number; backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type LeaseHeartbeatPayload = { @@ -139,6 +145,10 @@ export type LeaseHeartbeatPayload = { runId?: string; leaseId?: string; ttlMs?: number; + backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type LeaseReleasePayload = { @@ -148,6 +158,10 @@ export type LeaseReleasePayload = { tenant?: string; runId?: string; leaseId?: string; + backend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type JsonRpcId = string | number | null; @@ -225,6 +239,37 @@ function optionalString( return value === undefined ? undefined : expectString(value, `${path}.${key}`); } +function optionalDeviceKey( + record: Record, + key: string, + path: string, +): string | undefined { + const value = optionalString(record, key, path); + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!trimmed || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + fail(`${path}.${key}`, 'Expected 1-256 printable characters'); + } + return value; +} + +function optionalIdentifier( + record: Record, + key: string, + path: string, + maxLength: number, +): string | undefined { + const value = optionalString(record, key, path); + if (value === undefined) return undefined; + if (value.length < 1 || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { + fail( + `${path}.${key}`, + `Expected 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen`, + ); + } + return value; +} + function optionalBoolean( record: Record, key: string, @@ -374,6 +419,9 @@ export const daemonCommandRequestSchema = schema((input, path) => leaseId: optionalString(meta, 'leaseId', `${path}.meta`), leaseTtlMs: optionalInteger(meta, 'leaseTtlMs', `${path}.meta`), leaseBackend: optionalEnum(meta, 'leaseBackend', LEASE_BACKENDS, `${path}.meta`), + leaseProvider: optionalIdentifier(meta, 'leaseProvider', `${path}.meta`, 64), + deviceKey: optionalDeviceKey(meta, 'deviceKey', `${path}.meta`), + clientId: optionalIdentifier(meta, 'clientId', `${path}.meta`, 128), sessionIsolation: optionalEnum( meta, 'sessionIsolation', @@ -431,6 +479,9 @@ function parseLeaseScope( tenantId?: string; tenant?: string; runId?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; } { return { token: optionalString(record, 'token', path), @@ -438,6 +489,9 @@ function parseLeaseScope( tenantId: optionalString(record, 'tenantId', path), tenant: optionalString(record, 'tenant', path), runId: optionalString(record, 'runId', path), + leaseProvider: optionalIdentifier(record, 'leaseProvider', path, 64), + deviceKey: optionalDeviceKey(record, 'deviceKey', path), + clientId: optionalIdentifier(record, 'clientId', path, 128), }; } @@ -456,6 +510,7 @@ export const leaseHeartbeatSchema = schema((input, path) ...parseLeaseScope(parsed.record, path), leaseId: parsed.leaseId, ttlMs: parsed.ttlMs, + backend: optionalEnum(parsed.record, 'backend', LEASE_BACKENDS, path), }; }); @@ -467,6 +522,7 @@ export const leaseReleaseSchema = schema((input, path) => { return { ...parseLeaseScope(record, path), leaseId: optionalString(record, 'leaseId', path), + backend: optionalEnum(record, 'backend', LEASE_BACKENDS, path), }; }); diff --git a/src/daemon-client-rpc.ts b/src/daemon-client-rpc.ts index a0b21a531..53f97ad90 100644 --- a/src/daemon-client-rpc.ts +++ b/src/daemon-client-rpc.ts @@ -143,6 +143,10 @@ function buildLeaseRpcParams( session: req.session, tenantId: req.meta?.tenantId, runId: req.meta?.runId, + leaseProvider: req.meta?.leaseProvider, + provider: req.meta?.leaseProvider, + clientId: req.meta?.clientId, + deviceKey: req.meta?.deviceKey, }; switch (command) { case 'lease_allocate': diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index 3c7431757..e1a8786b7 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -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__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 4d129dd8e..8a52d8366 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -29,12 +29,15 @@ 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', }); } @@ -202,3 +205,46 @@ test('router allows pre-open requests for different devices to proceed concurren expect(secondResponse.ok).toBe(true); expect(maxActiveEnsures).toBe(2); }); + +test('open rejects mismatched proxy lease metadata before dispatch side effects', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-proxy-lease-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'proxy', + runId: 'proxy-client-1', + backend: 'ios-instance', + provider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-OTHER', + ttlMs: 300_000, + }); + const device = makeIosDevice('SIM-001'); + mockResolveTargetDevice.mockResolvedValue(device); + + const handler = createOpenHandler(sessionStore, leaseRegistry); + const response = await handler({ + token: 'test-token', + session: 'adc-proxy', + command: 'open', + positionals: ['com.example.App'], + flags: { platform: 'ios' }, + meta: { + requestId: 'req-open-proxy-lease', + sessionIsolation: 'tenant', + tenantId: 'proxy', + runId: 'proxy-client-1', + leaseId: lease.leaseId, + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'ios:mobile:SIM-001', + }, + }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('UNAUTHORIZED'); + } + expect(mockDispatch).not.toHaveBeenCalled(); + expect(sessionStore.get('proxy:adc-proxy')).toBeUndefined(); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index ad36e01ac..186ff2a9c 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -16,6 +16,9 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise; + +type SessionLeaseSource = { + lease?: SessionLease | null; + deviceLease?: SessionLease | null; }; export function resolveLeaseScope(req: Pick): LeaseScope { @@ -16,5 +36,72 @@ export function resolveLeaseScope(req: Pick): L leaseId: req.meta?.leaseId ?? req.flags?.leaseId, leaseTtlMs: req.meta?.leaseTtlMs, leaseBackend: req.meta?.leaseBackend, + leaseProvider: + req.meta?.leaseProvider ?? + readFlagString(req.flags, 'leaseProvider') ?? + readFlagString(req.flags, 'provider'), + deviceKey: req.meta?.deviceKey ?? readFlagString(req.flags, 'deviceKey'), + clientId: req.meta?.clientId ?? readFlagString(req.flags, 'clientId'), }; } + +export function buildSessionLeaseFromRequest( + req: Pick, +): SessionLease | undefined { + const leaseScope = resolveLeaseScope(req); + if (!leaseScope.tenantId || !leaseScope.runId || !leaseScope.leaseId) { + return undefined; + } + return stripUndefined({ + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + leaseId: leaseScope.leaseId, + leaseBackend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + }); +} + +export function resolveRequestOrSessionLeaseScope( + req: Pick, + session?: SessionLeaseSource | null, +): LeaseScope { + const requestScope = resolveLeaseScope(req); + const sessionLease = session?.lease ?? session?.deviceLease ?? undefined; + return stripUndefined({ + tenantId: requestScope.tenantId ?? sessionLease?.tenantId, + runId: requestScope.runId ?? sessionLease?.runId, + leaseId: requestScope.leaseId ?? sessionLease?.leaseId, + leaseTtlMs: requestScope.leaseTtlMs, + leaseBackend: requestScope.leaseBackend ?? sessionLease?.leaseBackend, + leaseProvider: requestScope.leaseProvider ?? sessionLease?.leaseProvider, + deviceKey: requestScope.deviceKey ?? sessionLease?.deviceKey, + clientId: requestScope.clientId ?? sessionLease?.clientId, + }); +} + +export function buildLeaseDiagnosticsContext( + leaseScope: LeaseScope | SessionLease | undefined, +): LeaseDiagnosticsContext | undefined { + if (!leaseScope) return undefined; + const context = stripUndefined({ + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + leaseId: leaseScope.leaseId, + leaseBackend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, + }); + return Object.keys(context).length > 0 ? context : undefined; +} + +function readFlagString(flags: object | undefined, key: string): string | undefined { + const value = (flags as Record | undefined)?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3f5a4ba3f..0273db9d0 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -1,18 +1,24 @@ import crypto from 'node:crypto'; +import type { LeaseBackend } from '../contracts.ts'; import { AppError } from '../utils/errors.ts'; import { normalizeTenantId } from './config.ts'; -import type { LeaseBackend } from '../contracts.ts'; -export type SimulatorLease = { +export type DeviceLease = { leaseId: string; tenantId: string; runId: string; backend: LeaseBackend; + leaseProvider?: string; + provider?: string; + deviceKey?: string; + clientId?: string; createdAt: number; heartbeatAt: number; expiresAt: number; }; +export type SimulatorLease = DeviceLease; + export type LeaseRegistryOptions = { maxActiveSimulatorLeases?: number; defaultLeaseTtlMs?: number; @@ -25,6 +31,10 @@ export type AllocateLeaseRequest = { tenantId: string; runId: string; backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -32,6 +42,11 @@ export type HeartbeatLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; ttlMs?: number; }; @@ -39,6 +54,11 @@ export type ReleaseLeaseRequest = { leaseId: string; tenantId?: string; runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; export type AdmissionRequest = { @@ -46,11 +66,16 @@ export type AdmissionRequest = { runId: string | undefined; leaseId: string | undefined; backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; }; const DEFAULT_LEASE_TTL_MS = 60_000; const MIN_LEASE_TTL_MS = 5_000; const MAX_LEASE_TTL_MS = 10 * 60_000; +const DEFAULT_LEASE_PROVIDER = 'default'; function normalizeRunId(raw: string | undefined): string | undefined { if (!raw) return undefined; @@ -75,9 +100,51 @@ function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); } +function normalizeDeviceKey(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); + } + return value; +} + +function normalizeClientId(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'client id', 128); +} + +function normalizeLeaseProviderFields(request: { + provider?: string; + leaseProvider?: string; +}): string | undefined { + const provider = normalizeAgentIdentifier(request.provider, 'lease provider', 64); + const leaseProvider = normalizeAgentIdentifier(request.leaseProvider, 'lease provider', 64); + if (provider && leaseProvider && provider !== leaseProvider) { + throw new AppError('INVALID_ARGS', 'Conflicting lease provider values.'); + } + return leaseProvider ?? provider; +} + +function normalizeAgentIdentifier( + raw: string | undefined, + label: string, + maxLength: number, +): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid ${label}. Use 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen.`, + ); + } + return value; +} + export class LeaseRegistry { - private readonly leases = new Map(); + private readonly leases = new Map(); private readonly runBindings = new Map(); + private readonly deviceBindings = new Map(); private readonly maxActiveSimulatorLeases: number; private readonly defaultLeaseTtlMs: number; private readonly minLeaseTtlMs: number; @@ -100,8 +167,11 @@ export class LeaseRegistry { this.now = options.now ?? (() => Date.now()); } - allocateLease(request: AllocateLeaseRequest): SimulatorLease { + allocateLease(request: AllocateLeaseRequest): DeviceLease { const backend = normalizeLeaseBackend(request.backend); + const provider = normalizeLeaseProviderFields(request); + const deviceKey = normalizeDeviceKey(request.deviceKey); + const clientId = normalizeClientId(request.clientId); const tenantId = normalizeTenantId(request.tenantId); if (!tenantId) { throw new AppError( @@ -118,32 +188,37 @@ export class LeaseRegistry { } this.cleanupExpiredLeases(); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); - const bindingKey = this.bindingKey(tenantId, runId, backend); + const bindingKey = this.bindingKey({ tenantId, runId, backend, provider, deviceKey }); const existingId = this.runBindings.get(bindingKey); if (existingId) { const existingLease = this.leases.get(existingId); if (existingLease) { + this.assertOptionalLeaseIdentityMatch(existingLease, { clientId }); return this.refreshLease(existingLease, leaseTtlMs); } this.runBindings.delete(bindingKey); } + this.assertDeviceAvailable({ backend, provider, deviceKey }); this.enforceCapacity(backend); const now = this.now(); - const lease: SimulatorLease = { + const lease: DeviceLease = { leaseId: crypto.randomBytes(16).toString('hex'), tenantId, runId, backend, + ...(provider ? { leaseProvider: provider, provider } : {}), + ...(deviceKey ? { deviceKey } : {}), + ...(clientId ? { clientId } : {}), createdAt: now, heartbeatAt: now, expiresAt: now + leaseTtlMs, }; this.leases.set(lease.leaseId, lease); - this.runBindings.set(bindingKey, lease.leaseId); + this.bindLease(lease); return { ...lease }; } - heartbeatLease(request: HeartbeatLeaseRequest): SimulatorLease { + heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { const leaseId = normalizeLeaseId(request.leaseId); if (!leaseId) { throw new AppError('INVALID_ARGS', 'Invalid lease id.'); @@ -155,7 +230,15 @@ export class LeaseRegistry { reason: 'LEASE_NOT_FOUND', }); } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + this.assertOptionalScopeMatch(lease, { + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } @@ -170,9 +253,17 @@ export class LeaseRegistry { if (!lease) { return { released: false }; } - this.assertOptionalScopeMatch(lease, request.tenantId, request.runId); + this.assertOptionalScopeMatch(lease, { + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); this.leases.delete(leaseId); - this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + this.unbindLease(lease); return { released: true }; } @@ -197,14 +288,18 @@ export class LeaseRegistry { reason: 'LEASE_NOT_FOUND', }); } - if (lease.backend !== backend || lease.tenantId !== tenantId || lease.runId !== runId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); - } + this.assertOptionalScopeMatch(lease, { + tenantId, + runId, + backend, + provider: request.provider, + leaseProvider: request.leaseProvider, + deviceKey: request.deviceKey, + clientId: request.clientId, + }); } - listActiveLeases(): SimulatorLease[] { + listActiveLeases(): DeviceLease[] { this.cleanupExpiredLeases(); return Array.from(this.leases.values()).map((entry) => ({ ...entry })); } @@ -214,7 +309,7 @@ export class LeaseRegistry { for (const lease of this.leases.values()) { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); - this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + this.unbindLease(lease); } } @@ -246,53 +341,171 @@ export class LeaseRegistry { return value; } - private refreshLease(lease: SimulatorLease, ttlMs: number): SimulatorLease { + private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { const now = this.now(); - const updated: SimulatorLease = { + const updated: DeviceLease = { ...lease, heartbeatAt: now, expiresAt: now + ttlMs, }; this.leases.set(updated.leaseId, updated); + this.bindLease(updated); + return { ...updated }; + } + + private bindLease(lease: DeviceLease): void { this.runBindings.set( - this.bindingKey(updated.tenantId, updated.runId, updated.backend), - updated.leaseId, + this.bindingKey({ + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + provider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + lease.leaseId, ); - return { ...updated }; + const deviceBindingKey = this.deviceBindingKey(lease); + if (deviceBindingKey) { + this.deviceBindings.set(deviceBindingKey, lease.leaseId); + } + } + + private unbindLease(lease: DeviceLease): void { + this.runBindings.delete( + this.bindingKey({ + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + provider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }), + ); + const deviceBindingKey = this.deviceBindingKey(lease); + if (deviceBindingKey) { + this.deviceBindings.delete(deviceBindingKey); + } + } + + private bindingKey(params: { + tenantId: string; + runId: string; + backend: LeaseBackend; + provider?: string; + deviceKey?: string; + }): string { + return JSON.stringify([ + params.tenantId, + params.runId, + params.backend, + params.provider ?? DEFAULT_LEASE_PROVIDER, + params.deviceKey ?? '*', + ]); } - private bindingKey(tenantId: string, runId: string, backend: LeaseBackend): string { - return `${tenantId}:${runId}:${backend}`; + private deviceBindingKey( + lease: Pick, + ): string | undefined { + if (!lease.deviceKey) return undefined; + return JSON.stringify([ + lease.backend, + lease.leaseProvider ?? DEFAULT_LEASE_PROVIDER, + lease.deviceKey, + ]); + } + + private assertDeviceAvailable(params: { + backend: LeaseBackend; + provider?: string; + deviceKey?: string; + }): void { + const deviceBindingKey = this.deviceBindingKey({ + backend: params.backend, + leaseProvider: params.provider, + deviceKey: params.deviceKey, + }); + if (!deviceBindingKey) return; + const activeLeaseId = this.deviceBindings.get(deviceBindingKey); + if (!activeLeaseId) return; + const activeLease = this.leases.get(activeLeaseId); + if (!activeLease) { + this.deviceBindings.delete(deviceBindingKey); + return; + } + throw new AppError('COMMAND_FAILED', 'Device is already leased', { + reason: 'DEVICE_LEASE_BUSY', + deviceKey: activeLease.deviceKey, + backend: activeLease.backend, + leaseProvider: activeLease.leaseProvider, + leaseId: activeLease.leaseId, + tenantId: activeLease.tenantId, + runId: activeLease.runId, + expiresAt: activeLease.expiresAt, + hint: 'Retry after the lease expires or close the owning session.', + }); } private assertOptionalScopeMatch( - lease: SimulatorLease, - tenantRaw: string | undefined, - runRaw: string | undefined, + lease: DeviceLease, + request: { + tenantId?: string; + runId?: string; + backend?: LeaseBackend; + provider?: string; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + }, ): void { - const tenantId = normalizeTenantId(tenantRaw); - const runId = normalizeRunId(runRaw); - if (tenantRaw && !tenantId) { + const tenantId = normalizeTenantId(request.tenantId); + const runId = normalizeRunId(request.runId); + if (request.tenantId && !tenantId) { throw new AppError( 'INVALID_ARGS', 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - if (runRaw && !runId) { + if (request.runId && !runId) { throw new AppError( 'INVALID_ARGS', 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - if (tenantId && lease.tenantId !== tenantId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); + const backend = request.backend ? normalizeLeaseBackend(request.backend) : undefined; + const provider = normalizeLeaseProviderFields(request); + const deviceKey = normalizeDeviceKey(request.deviceKey); + const clientId = normalizeClientId(request.clientId); + if ( + (tenantId && lease.tenantId !== tenantId) || + (runId && lease.runId !== runId) || + (backend && lease.backend !== backend) + ) { + this.throwScopeMismatch(); } - if (runId && lease.runId !== runId) { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); + this.assertOptionalLeaseIdentityMatch(lease, { provider, deviceKey, clientId }); + } + + private assertOptionalLeaseIdentityMatch( + lease: DeviceLease, + request: { + provider?: string; + deviceKey?: string; + clientId?: string; + }, + ): void { + if (request.provider && lease.leaseProvider !== request.provider) { + this.throwScopeMismatch(); + } + if (request.deviceKey && lease.deviceKey !== request.deviceKey) { + this.throwScopeMismatch(); + } + if (request.clientId && lease.clientId !== request.clientId) { + this.throwScopeMismatch(); } } + + private throwScopeMismatch(): never { + throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { + reason: 'LEASE_SCOPE_MISMATCH', + }); + } } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 1930a0784..032b18494 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -62,5 +62,8 @@ export function assertRequestLeaseAdmission( runId: leaseScope.runId, leaseId: leaseScope.leaseId, backend: leaseScope.leaseBackend, + leaseProvider: leaseScope.leaseProvider, + deviceKey: leaseScope.deviceKey, + clientId: leaseScope.clientId, }); } diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 86524d179..87a4ef088 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-'), ); @@ -738,6 +740,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/runner-lease.ts b/src/platforms/ios/runner-lease.ts index 506fcaac8..956720bcd 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -16,6 +16,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 +74,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`, @@ -146,14 +152,20 @@ function isSameStateDirRunnerLease(lease: RunnerLease): boolean { } function readCurrentStateDir(): string | undefined { + if (runnerLeaseOwnerStateDir) return runnerLeaseOwnerStateDir; return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } function buildBusyRunnerLeaseHint(lease: RunnerLease): 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}.` + : ''; return [ - `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`, + `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.${current}`, '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(' '); } diff --git a/src/remote-config-core.ts b/src/remote-config-core.ts index 69c5c8e38..c5f8e8011 100644 --- a/src/remote-config-core.ts +++ b/src/remote-config-core.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { - REMOTE_CONFIG_FIELD_SPECS, + REMOTE_CONFIG_PROFILE_FIELD_SPECS, getRemoteConfigEnvNames, getRemoteConfigFieldSpec, type RemoteConfigProfile, @@ -74,7 +74,7 @@ function readRemoteConfigEnvDefaults( env: Record = process.env, ): RemoteConfigProfile { const profile: RemoteConfigProfile = {}; - for (const spec of REMOTE_CONFIG_FIELD_SPECS) { + for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const envMatch = getRemoteConfigEnvNames(spec.key) .map((name) => ({ name, value: env[name] })) .find((entry) => typeof entry.value === 'string' && entry.value.trim().length > 0); @@ -95,7 +95,7 @@ function mergeRemoteConfigProfile( const merged: RemoteConfigProfile = {}; for (const profile of profiles) { if (!profile) continue; - for (const spec of REMOTE_CONFIG_FIELD_SPECS) { + for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const value = profile[spec.key]; if (value !== undefined) { (merged as Record)[spec.key] = value; diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 6ca159a2a..e86d975cb 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -35,6 +35,9 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & { runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; platform?: PlatformSelector; target?: DeviceTarget; device?: string; @@ -109,8 +112,19 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'metroNoInstallDeps', type: 'boolean' }, ] as const satisfies readonly RemoteConfigFieldSpec[]; +const REMOTE_CONFIG_LEASE_FIELD_SPECS = [ + { key: 'leaseProvider', type: 'string', env: false }, + { key: 'deviceKey', type: 'string', env: false }, + { key: 'clientId', type: 'string', env: false }, +] as const satisfies readonly RemoteConfigFieldSpec[]; + +export const REMOTE_CONFIG_PROFILE_FIELD_SPECS = [ + ...REMOTE_CONFIG_FIELD_SPECS, + ...REMOTE_CONFIG_LEASE_FIELD_SPECS, +] as const satisfies readonly RemoteConfigFieldSpec[]; + const remoteConfigFieldSpecByKey = new Map( - REMOTE_CONFIG_FIELD_SPECS.map((spec) => [spec.key, spec]), + REMOTE_CONFIG_PROFILE_FIELD_SPECS.map((spec) => [spec.key, spec]), ); export function getRemoteConfigFieldSpec( diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index e4354d152..f4a112e87 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -21,6 +21,9 @@ export type RemoteConnectionState = { runId: string; leaseId?: string; leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; runtime?: SessionRuntimeHints; @@ -33,9 +36,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: { @@ -129,6 +138,7 @@ export function resolveRemoteConnectionDefaults(options: { const profile = resolveConnectionProfile(state, options); return { runtime: state.runtime, + connection: buildRemoteConnectionRequestMetadata(state), flags: { ...profile, remoteConfig: state.remoteConfigPath, @@ -147,6 +157,17 @@ export function resolveRemoteConnectionDefaults(options: { }; } +export function buildRemoteConnectionRequestMetadata( + state: RemoteConnectionState, +): RemoteConnectionRequestMetadata | undefined { + const connection = stripUndefined({ + leaseProvider: state.leaseProvider, + deviceKey: state.deviceKey, + clientId: state.clientId, + }); + return Object.keys(connection).length > 0 ? connection : undefined; +} + export function hashRemoteConfigFile(configPath: string): string { try { return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); @@ -264,6 +285,10 @@ function safeStateName(value: string): string { return `${safe}-${suffix}`; } +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} + function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; @@ -280,6 +305,9 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState typeof record.runId === 'string' && (record.leaseId === undefined || typeof record.leaseId === 'string') && (record.leaseBackend === undefined || typeof record.leaseBackend === 'string') && + (record.leaseProvider === undefined || typeof record.leaseProvider === 'string') && + (record.deviceKey === undefined || typeof record.deviceKey === 'string') && + (record.clientId === undefined || typeof record.clientId === 'string') && typeof record.connectedAt === 'string' && typeof record.updatedAt === 'string' ); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 95349d3ce..528165416 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');