From 61731973afc7a8f44e0a6ccf604adfc449fd102b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Apr 2026 16:56:13 +0200 Subject: [PATCH] fix: defer remote connect lease and metro setup --- .../agent-device/references/remote-tenancy.md | 18 +- src/__tests__/cli-batch.test.ts | 138 ++-- src/__tests__/cli-clipboard.test.ts | 3 + src/__tests__/cli-close.test.ts | 5 + src/__tests__/cli-config.test.ts | 166 +++- src/__tests__/cli-diagnostics.test.ts | 78 +- src/__tests__/cli-diff.test.ts | 11 +- src/__tests__/cli-help.test.ts | 3 + src/__tests__/cli-logs.test.ts | 3 + src/__tests__/cli-network.test.ts | 3 + src/__tests__/cli-test-env.ts | 46 ++ src/__tests__/remote-connection.test.ts | 722 ++++++++++++++---- src/cli.ts | 126 ++- src/cli/commands/connection-runtime.ts | 377 +++++++++ src/cli/commands/connection.ts | 295 ++----- src/daemon/handlers/__tests__/session.test.ts | 52 ++ src/daemon/handlers/session-batch.ts | 2 +- src/remote-connection-state.ts | 8 +- website/docs/docs/commands.md | 5 +- 19 files changed, 1479 insertions(+), 582 deletions(-) create mode 100644 src/__tests__/cli-test-env.ts create mode 100644 src/cli/commands/connection-runtime.ts diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 118a2c65b..1a2caef9d 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -13,7 +13,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux ## Most common mistake to avoid -Do not run remote tenant work by repeating `--remote-config` on every command. `--remote-config` is a `connect` input. After connecting, use normal `agent-device` commands; the active connection supplies daemon URL, tenant, run, lease, and prepared Metro runtime context. +Do not run remote tenant work by repeating `--remote-config` on every command. `--remote-config` is a `connect` input. After connecting, use normal `agent-device` commands; the active connection supplies daemon URL, tenant, run, and session context, then resolves lease and Metro details only when a later command actually needs them. ## Preferred remote flow @@ -34,7 +34,7 @@ agent-device fill @e3 "test@example.com" agent-device disconnect ``` -`connect` resolves the remote profile, verifies daemon reachability through the normal client path, allocates or refreshes the tenant lease, prepares local Metro when the profile has Metro fields, starts the local Metro companion when the bridge needs it, and writes local non-secret connection state for later commands. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease, and removes local connection state. +`connect` resolves the remote profile, generates a local session name when the profile omits one, stores local non-secret connection state, and defers tenant lease allocation plus Metro preparation until a later command needs them. When a command such as `open`, `install`, `apps`, or `snapshot` needs a lease, the client allocates or refreshes it from the connected scope. When a command needs Metro runtime hints, the client prepares Metro locally at that point and starts the local Metro companion when the bridge needs it, including `batch` runs whose steps open an app. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease when one was allocated, and removes local connection state. After `connect`, normal `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command. @@ -63,18 +63,26 @@ Example `remote-config.json` shape: "tenant": "acme", "runId": "run-123", "sessionIsolation": "tenant", - "session": "adc-android", "platform": "android", + "metroPublicBaseUrl": "http://127.0.0.1:8081" +} +``` + +Optional overrides stay available for advanced cases: + +```json +{ + "session": "adc-android", "leaseBackend": "android-instance", "metroProjectRoot": ".", - "metroPublicBaseUrl": "http://127.0.0.1:8081", + "metroKind": "expo", "metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123" } ``` - Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state. - Omit Metro fields for non-React Native flows. -- Put `tenant`, `runId`, `session`, `sessionIsolation`, `platform`, and `leaseBackend` in the remote profile when possible so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. +- Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow. - Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. - For React Native Metro runs with `metroProxyBaseUrl`, `agent-device >= 0.11.12` can manage the local companion tunnel, but Metro itself still needs to be running locally. - Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override. diff --git a/src/__tests__/cli-batch.test.ts b/src/__tests__/cli-batch.test.ts index 1abc33d62..1bddbb664 100644 --- a/src/__tests__/cli-batch.test.ts +++ b/src/__tests__/cli-batch.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -25,6 +26,9 @@ type RunResult = { async function runCliCapture( argv: string[], responder?: (req: Omit) => Promise, + options?: { + env?: Record; + }, ): Promise { let stdout = ''; let stderr = ''; @@ -34,9 +38,11 @@ async function runCliCapture( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); - const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-batch-')); - process.env.AGENT_DEVICE_STATE_DIR = stateDir; + const restoreEnv = installIsolatedCliTestEnv({ + ...(options?.env ?? {}), + AGENT_DEVICE_STATE_DIR: stateDir, + }); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -64,8 +70,7 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { - if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; - else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir; + restoreEnv(); fs.rmSync(stateDir, { recursive: true, force: true }); process.exit = originalExit; process.stdout.write = originalStdoutWrite; @@ -131,93 +136,76 @@ test('batch --steps-file rejects invalid JSON payload', async () => { }); test('batch forwards strip lock policy for nested steps when bound session uses strip mode', async () => { - const previousSession = process.env.AGENT_DEVICE_SESSION; - const previousPlatform = process.env.AGENT_DEVICE_PLATFORM; - const previousLock = process.env.AGENT_DEVICE_SESSION_LOCK; - process.env.AGENT_DEVICE_SESSION = 'qa-ios'; - process.env.AGENT_DEVICE_PLATFORM = 'ios'; - process.env.AGENT_DEVICE_SESSION_LOCK = 'strip'; - - try { - const result = await runCliCapture([ + const result = await runCliCapture( + [ 'batch', '--steps', '[{"command":"snapshot","flags":{"platform":"android","serial":"emulator-5554"}}]', '--json', - ]); - assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip'); - assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); - const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; - assert.equal(stepFlags.platform, 'android'); - assert.equal(stepFlags.serial, 'emulator-5554'); - } finally { - if (previousSession === undefined) delete process.env.AGENT_DEVICE_SESSION; - else process.env.AGENT_DEVICE_SESSION = previousSession; - if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM; - else process.env.AGENT_DEVICE_PLATFORM = previousPlatform; - if (previousLock === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCK; - else process.env.AGENT_DEVICE_SESSION_LOCK = previousLock; - } + ], + undefined, + { + env: { + AGENT_DEVICE_SESSION: 'qa-ios', + AGENT_DEVICE_PLATFORM: 'ios', + AGENT_DEVICE_SESSION_LOCK: 'strip', + }, + }, + ); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip'); + assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); + const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; + assert.equal(stepFlags.platform, 'android'); + assert.equal(stepFlags.serial, 'emulator-5554'); }); test('batch forwards reject lock policy for target retargeting', async () => { - const previousPlatform = process.env.AGENT_DEVICE_PLATFORM; - const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED; - process.env.AGENT_DEVICE_PLATFORM = 'ios'; - process.env.AGENT_DEVICE_SESSION_LOCKED = '1'; - - try { - const result = await runCliCapture([ - 'batch', - '--steps', - '[{"command":"open","flags":{"target":"tv"}}]', - '--json', - ]); - assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject'); - const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; - assert.equal(stepFlags.target, 'tv'); - } finally { - if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM; - else process.env.AGENT_DEVICE_PLATFORM = previousPlatform; - if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED; - else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked; - } + const result = await runCliCapture( + ['batch', '--steps', '[{"command":"open","flags":{"target":"tv"}}]', '--json'], + undefined, + { + env: { + AGENT_DEVICE_PLATFORM: 'ios', + AGENT_DEVICE_SESSION_LOCKED: '1', + }, + }, + ); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject'); + const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; + assert.equal(stepFlags.target, 'tv'); }); test('batch session lock flags apply to nested steps without env configuration', async () => { - const previousPlatform = process.env.AGENT_DEVICE_PLATFORM; - const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED; - process.env.AGENT_DEVICE_PLATFORM = 'ios'; - process.env.AGENT_DEVICE_SESSION_LOCKED = '0'; - - try { - const result = await runCliCapture([ + const result = await runCliCapture( + [ 'batch', '--session-lock', 'strip', '--steps', '[{"command":"snapshot","flags":{"target":"tv","serial":"emulator-5554"}}]', '--json', - ]); - assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip'); - assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); - assert.equal(result.calls[0]?.flags?.platform, 'ios'); - const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; - assert.equal(stepFlags.platform, 'ios'); - assert.equal(stepFlags.target, 'tv'); - assert.equal(stepFlags.serial, 'emulator-5554'); - } finally { - if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM; - else process.env.AGENT_DEVICE_PLATFORM = previousPlatform; - if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED; - else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked; - } + ], + undefined, + { + env: { + AGENT_DEVICE_PLATFORM: 'ios', + AGENT_DEVICE_SESSION_LOCKED: '0', + }, + }, + ); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip'); + assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); + assert.equal(result.calls[0]?.flags?.platform, 'ios'); + const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {}; + assert.equal(stepFlags.platform, 'ios'); + assert.equal(stepFlags.target, 'tv'); + assert.equal(stepFlags.serial, 'emulator-5554'); }); test('batch step without explicit platform inherits parent platform over env default', async () => { diff --git a/src/__tests__/cli-clipboard.test.ts b/src/__tests__/cli-clipboard.test.ts index 3af306a4a..757f6f108 100644 --- a/src/__tests__/cli-clipboard.test.ts +++ b/src/__tests__/cli-clipboard.test.ts @@ -2,6 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -31,6 +32,7 @@ async function runCliCapture( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -55,6 +57,7 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index 19c375928..842df0cf6 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { runCli } from '../cli.ts'; import { AppError } from '../utils/errors.ts'; import type { DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -29,6 +30,7 @@ async function runCliCapture(argv: string[]): Promise { const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -56,6 +58,7 @@ async function runCliCapture(argv: string[]): Promise { if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; @@ -77,6 +80,7 @@ async function runCliCaptureWithErrorDetails( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -101,6 +105,7 @@ async function runCliCaptureWithErrorDetails( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 57f280f24..526d4d059 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -4,8 +4,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runCli } from '../cli.ts'; -import { hashRemoteConfigFile, readRemoteConnectionState } from '../remote-connection-state.ts'; +import { + hashRemoteConfigFile, + readActiveConnectionState, + readRemoteConnectionState, +} from '../remote-connection-state.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -40,16 +45,11 @@ async function runCliCapture( const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); const originalCwd = process.cwd(); - const previousEnv = new Map(); + const restoreEnv = installIsolatedCliTestEnv(options?.env ?? {}); if (options?.cwd) { process.chdir(options.cwd); } - for (const [key, value] of Object.entries(options?.env ?? {})) { - previousEnv.set(key, process.env[key]); - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -63,12 +63,13 @@ async function runCliCapture( return true; }) as typeof process.stderr.write; - const sendToDaemon = - options?.sendToDaemon ?? - (async (req: Omit): Promise => { - calls.push(req); - return { ok: true, data: {} }; - }); + const sendToDaemon = async (req: Omit): Promise => { + calls.push(req); + if (options?.sendToDaemon) { + return await options.sendToDaemon(req); + } + return { ok: true, data: {} }; + }; try { await runCli(argv, { sendToDaemon }); @@ -76,14 +77,11 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; process.chdir(originalCwd); - for (const [key, value] of previousEnv.entries()) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } } return { code, stdout, stderr, calls }; @@ -274,18 +272,37 @@ test('active remote connection defaults override generic config and env for remo AGENT_DEVICE_SESSION: 'env-session', AGENT_DEVICE_PLATFORM: 'ios', }, + sendToDaemon: async (req) => { + if (req.command === 'lease_heartbeat') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-123', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + return { ok: true, data: {} }; + }, }); assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.session, 'env-session'); - assert.equal(result.calls[0]?.flags?.platform, 'android'); + assert.equal(result.calls.length, 2); + assert.equal(result.calls[0]?.command, 'lease_heartbeat'); + assert.equal(result.calls[1]?.session, 'env-session'); + assert.equal(result.calls[1]?.flags?.platform, 'android'); assert.equal( - result.calls[0]?.flags?.daemonBaseUrl, + result.calls[1]?.flags?.daemonBaseUrl, 'http://remote-mac.example.test:9124/agent-device', ); assert.equal(result.calls[0]?.meta?.tenantId, 'acme'); assert.equal(result.calls[0]?.meta?.leaseId, 'lease-123'); + assert.equal(result.calls[1]?.meta?.tenantId, 'acme'); + assert.equal(result.calls[1]?.meta?.leaseId, 'lease-123'); fs.rmSync(root, { recursive: true, force: true }); }); @@ -332,6 +349,19 @@ test('install-from-source uses active remote connection lease binding', async () env: { HOME: home }, sendToDaemon: async (req) => { calls.push(req); + if (req.command === 'lease_heartbeat') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-demo-001', + tenantId: 'micha-pierzcha-a', + runId: 'demo-run-001', + backend: 'android-instance', + }, + }, + }; + } return { ok: true, data: { @@ -348,14 +378,92 @@ test('install-from-source uses active remote connection lease binding', async () const payload = JSON.parse(result.stdout); assert.equal(payload.success, true); assert.equal(payload.data.launchTarget, 'com.example.demo'); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.meta?.tenantId, 'micha-pierzcha-a'); - assert.equal(calls[0]?.meta?.runId, 'demo-run-001'); - assert.equal(calls[0]?.meta?.leaseId, 'lease-demo-001'); - assert.equal(calls[0]?.flags?.tenant, 'micha-pierzcha-a'); - assert.equal(calls[0]?.flags?.runId, 'demo-run-001'); - assert.equal(calls[0]?.flags?.leaseId, 'lease-demo-001'); - assert.deepEqual(calls[0]?.positionals, []); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, 'lease_heartbeat'); + assert.equal(calls[1]?.meta?.tenantId, 'micha-pierzcha-a'); + assert.equal(calls[1]?.meta?.runId, 'demo-run-001'); + assert.equal(calls[1]?.meta?.leaseId, 'lease-demo-001'); + assert.equal(calls[1]?.flags?.tenant, 'micha-pierzcha-a'); + assert.equal(calls[1]?.flags?.runId, 'demo-run-001'); + assert.equal(calls[1]?.flags?.leaseId, 'lease-demo-001'); + assert.deepEqual(calls[1]?.positionals, []); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('minimal remote connect defers lease allocation until a platform-bound command runs', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + tenant: 'acme', + sessionIsolation: 'tenant', + runId: 'run-123', + platform: 'android', + }), + 'utf8', + ); + + const connectResult = await runCliCapture( + ['connect', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + }, + ); + + assert.equal(connectResult.code, null); + assert.equal(connectResult.calls.length, 0); + const connected = readActiveConnectionState({ stateDir }); + assert.match(connected?.session ?? '', /^adc-[a-z0-9]+$/); + assert.equal(connected?.leaseId, undefined); + assert.equal(connected?.leaseBackend, 'android-instance'); + + const calls: Array> = []; + const appsResult = await runCliCapture(['apps', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + calls.push(req); + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-123', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + if (req.command === 'apps') { + return { + ok: true, + data: { + apps: ['com.example.demo'], + }, + }; + } + throw new Error(`unexpected daemon command: ${req.command}`); + }, + }); + + assert.equal(appsResult.code, null); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.command, 'lease_allocate'); + assert.equal(calls[0]?.meta?.tenantId, 'acme'); + assert.equal(calls[0]?.meta?.runId, 'run-123'); + assert.equal(calls[0]?.meta?.leaseBackend, 'android-instance'); + assert.equal(calls[1]?.command, 'apps'); + assert.equal(calls[1]?.flags?.leaseId, 'lease-123'); + assert.equal(calls[1]?.meta?.leaseId, 'lease-123'); + assert.equal(calls[1]?.flags?.platform, 'android'); + assert.equal(readActiveConnectionState({ stateDir })?.leaseId, 'lease-123'); fs.rmSync(root, { recursive: true, force: true }); }); diff --git a/src/__tests__/cli-diagnostics.test.ts b/src/__tests__/cli-diagnostics.test.ts index fb4fc7455..c1928bf2f 100644 --- a/src/__tests__/cli-diagnostics.test.ts +++ b/src/__tests__/cli-diagnostics.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; import { resolveDaemonPaths } from '../daemon/config.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -26,6 +27,9 @@ type RunResult = { async function runCliCapture( argv: string[], responder: (req: Omit) => Promise, + options?: { + env?: Record; + }, ): Promise { let stdout = ''; let stderr = ''; @@ -35,9 +39,11 @@ async function runCliCapture( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); - const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-diagnostics-')); - process.env.AGENT_DEVICE_STATE_DIR = stateDir; + const restoreEnv = installIsolatedCliTestEnv({ + ...(options?.env ?? {}), + AGENT_DEVICE_STATE_DIR: stateDir, + }); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -62,8 +68,7 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { - if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; - else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir; + restoreEnv(); fs.rmSync(stateDir, { recursive: true, force: true }); process.exit = originalExit; process.stdout.write = originalStdoutWrite; @@ -99,11 +104,6 @@ test('cli does not tail local daemon log when remote daemon base URL is set', as fs.mkdirSync(path.dirname(daemonPaths.logPath), { recursive: true }); fs.writeFileSync(daemonPaths.logPath, 'REMOTE_TAIL_SENTINEL\n', 'utf8'); - const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL; - const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; - process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device'; - process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret'; - try { const result = await runCliCapture( ['clipboard', 'write', 'hello', '--debug', '--state-dir', stateDir], @@ -114,15 +114,17 @@ test('cli does not tail local daemon log when remote daemon base URL is set', as data: { action: 'write', message: 'Clipboard updated' }, }; }, + { + env: { + AGENT_DEVICE_DAEMON_BASE_URL: 'http://remote-mac.example.test:7777/agent-device', + AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'remote-secret', + }, + }, ); assert.equal(result.code, null); assert.equal(result.stdout.includes('REMOTE_TAIL_SENTINEL'), false); assert.match(result.stdout, /Clipboard updated/); } finally { - if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL; - else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl; - if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; - else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken; fs.rmSync(stateDir, { recursive: true, force: true }); } }); @@ -206,10 +208,9 @@ test('cli preserves --out for client-backed screenshot', async () => { }); test('cli applies AGENT_DEVICE_PLATFORM to client-backed commands', async () => { - const previousPlatform = process.env.AGENT_DEVICE_PLATFORM; - process.env.AGENT_DEVICE_PLATFORM = 'android'; - try { - const result = await runCliCapture(['open', 'com.example.app', '--json'], async () => ({ + const result = await runCliCapture( + ['open', 'com.example.app', '--json'], + async () => ({ ok: true, data: { app: 'com.example.app', @@ -218,13 +219,11 @@ test('cli applies AGENT_DEVICE_PLATFORM to client-backed commands', async () => device: 'Pixel 9', id: 'emulator-5554', }, - })); - assert.equal(result.code, null); - assert.equal(result.calls[0]?.flags?.platform, 'android'); - } finally { - if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM; - else process.env.AGENT_DEVICE_PLATFORM = previousPlatform; - } + }), + { env: { AGENT_DEVICE_PLATFORM: 'android' } }, + ); + assert.equal(result.code, null); + assert.equal(result.calls[0]?.flags?.platform, 'android'); }); test('cli prints success acknowledgment for client-backed open in human mode', async () => { @@ -263,27 +262,20 @@ test('cli prints success acknowledgment for daemon-backed mutating commands in h }); test('cli forwards bound-session lock policy when session defaults are configured', async () => { - const previousSession = process.env.AGENT_DEVICE_SESSION; - const previousPlatform = process.env.AGENT_DEVICE_PLATFORM; - process.env.AGENT_DEVICE_SESSION = 'qa-ios'; - process.env.AGENT_DEVICE_PLATFORM = 'ios'; - try { - const result = await runCliCapture(['snapshot', '--device', 'Pixel 9', '--json'], async () => ({ + const result = await runCliCapture( + ['snapshot', '--device', 'Pixel 9', '--json'], + async () => ({ ok: true, data: {}, - })); - assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject'); - assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); - assert.equal(result.calls[0]?.flags?.platform, 'ios'); - assert.equal(result.calls[0]?.flags?.device, 'Pixel 9'); - } finally { - if (previousSession === undefined) delete process.env.AGENT_DEVICE_SESSION; - else process.env.AGENT_DEVICE_SESSION = previousSession; - if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM; - else process.env.AGENT_DEVICE_PLATFORM = previousPlatform; - } + }), + { env: { AGENT_DEVICE_SESSION: 'qa-ios', AGENT_DEVICE_PLATFORM: 'ios' } }, + ); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject'); + assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios'); + assert.equal(result.calls[0]?.flags?.platform, 'ios'); + assert.equal(result.calls[0]?.flags?.device, 'Pixel 9'); }); test('cli session lock flag overrides environment for a single invocation', async () => { diff --git a/src/__tests__/cli-diff.test.ts b/src/__tests__/cli-diff.test.ts index e76ad6f53..997f923b9 100644 --- a/src/__tests__/cli-diff.test.ts +++ b/src/__tests__/cli-diff.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { PNG } from 'pngjs'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -57,15 +58,14 @@ async function runCliCapture( const originalStderrWrite = process.stderr.write.bind(process.stderr); const originalForceColor = process.env.FORCE_COLOR; const originalNoColor = process.env.NO_COLOR; - const originalHome = process.env.HOME; const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-home-')); + const restoreEnv = installIsolatedCliTestEnv( + options.preserveHome ? { HOME: process.env.HOME } : { HOME: tempHome }, + ); // Disable ANSI colors so assertions can match plain text process.env.FORCE_COLOR = '0'; delete process.env.NO_COLOR; - if (!options.preserveHome) { - process.env.HOME = tempHome; - } (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -140,8 +140,7 @@ async function runCliCapture( else delete process.env.FORCE_COLOR; if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; else delete process.env.NO_COLOR; - if (typeof originalHome === 'string') process.env.HOME = originalHome; - else delete process.env.HOME; + restoreEnv(); fs.rmSync(tempHome, { recursive: true, force: true }); } diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index aa99749f9..bee4c1eef 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -2,6 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { runCli } from '../cli.ts'; import type { DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -28,6 +29,7 @@ async function runCliCapture(argv: string[]): Promise { const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -52,6 +54,7 @@ async function runCliCapture(argv: string[]): Promise { if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/src/__tests__/cli-logs.test.ts b/src/__tests__/cli-logs.test.ts index 47d1bc0de..6b64433c1 100644 --- a/src/__tests__/cli-logs.test.ts +++ b/src/__tests__/cli-logs.test.ts @@ -2,6 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -31,6 +32,7 @@ async function runCliCapture( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -55,6 +57,7 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index 40a59fcbf..e747791d5 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { runCli } from '../cli.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; class ExitSignal extends Error { public readonly code: number; @@ -34,6 +35,7 @@ async function runCliCapture( const originalExit = process.exit; const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); + const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((nextCode?: number) => { throw new ExitSignal(nextCode ?? 0); @@ -58,6 +60,7 @@ async function runCliCapture( if (error instanceof ExitSignal) code = error.code; else throw error; } finally { + restoreEnv(); process.exit = originalExit; process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/src/__tests__/cli-test-env.ts b/src/__tests__/cli-test-env.ts new file mode 100644 index 000000000..4571ea428 --- /dev/null +++ b/src/__tests__/cli-test-env.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export function installIsolatedCliTestEnv( + explicitEnv: Record = {}, +): () => void { + const previousEnv = new Map(); + const explicitKeys = new Set(Object.keys(explicitEnv)); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-env-')); + const tempHome = path.join(tempRoot, 'home'); + fs.mkdirSync(path.join(tempHome, '.agent-device'), { recursive: true }); + + for (const key of Object.keys(process.env)) { + if (!key.startsWith('AGENT_DEVICE_') || explicitKeys.has(key)) continue; + previousEnv.set(key, process.env[key]); + delete process.env[key]; + } + + if (!explicitKeys.has('HOME')) { + previousEnv.set('HOME', process.env.HOME); + process.env.HOME = tempHome; + } + + for (const [key, value] of Object.entries(explicitEnv)) { + if (!previousEnv.has(key)) { + previousEnv.set(key, process.env[key]); + } + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + return () => { + for (const [key, value] of previousEnv.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }; +} diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index ffe5d4962..99d416110 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -13,15 +13,16 @@ import { connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; +import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; import { hashRemoteConfigFile, + readActiveConnectionState, readRemoteConnectionState, writeRemoteConnectionState, } from '../remote-connection-state.ts'; -import type { AgentDeviceClient, MetroPrepareOptions } from '../client.ts'; -import type { LeaseBackend } from '../contracts.ts'; +import type { AgentDeviceClient } from '../client.ts'; afterEach(() => { vi.clearAllMocks(); @@ -112,7 +113,7 @@ function createTestClient( }; } -test('connect allocates a lease, prepares Metro, and writes connection state', async () => { +test('connect auto-generates a local session and writes minimal remote state', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-')); const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); @@ -120,8 +121,6 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example.test' }), ); - let observedBackend: LeaseBackend | undefined; - let observedPrepare: MetroPrepareOptions | undefined; await captureStdout(async () => { await connectCommand({ @@ -137,61 +136,69 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a tenant: 'acme', sessionIsolation: 'tenant', runId: 'run-123', - session: 'adc-android', - platform: 'android', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', }, - client: createTestClient({ - allocate: async (request) => { - observedBackend = request.leaseBackend; - return { - leaseId: 'lease-1', - tenantId: request.tenant, - runId: request.runId, - backend: request.leaseBackend ?? 'android-instance', - }; - }, - prepare: async (options) => { - observedPrepare = options; - return { - projectRoot: '/tmp/project', - kind: 'react-native', - dependenciesInstalled: false, - packageManager: null, - started: false, - reused: true, - pid: 0, - logPath: '/tmp/project/.agent-device/metro.log', - statusUrl: 'http://127.0.0.1:8081/status', - runtimeFilePath: null, - iosRuntime: { platform: 'ios' }, - androidRuntime: { platform: 'android', bundleUrl: 'https://bundle.example.test' }, - bridge: null, - }; - }, - }), + client: createTestClient(), }); }); - const state = readRemoteConnectionState({ stateDir, session: 'adc-android' }); - assert.equal(observedBackend, 'android-instance'); - assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath); - assert.deepEqual(observedPrepare?.bridgeScope, { - tenantId: 'acme', - runId: 'run-123', - leaseId: 'lease-1', - }); - assert.equal(state?.leaseId, 'lease-1'); + const state = readActiveConnectionState({ stateDir }); + assert.match(state?.session ?? '', /^adc-[a-z0-9]+$/); + assert.equal(state?.leaseId, undefined); + assert.equal(state?.leaseBackend, undefined); assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath)); assert.deepEqual(state?.daemon, { baseUrl: 'https://daemon.example.test/agent-device?tenant=acme', }); - assert.equal(state?.metro?.projectRoot, '/tmp/project'); - assert.deepEqual(state?.runtime, { - platform: 'android', - bundleUrl: 'https://bundle.example.test', + assert.equal(state?.metro, undefined); + assert.equal(state?.runtime, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect without a session reuses the active generated connection', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-idempotent-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ daemonBaseUrl: 'https://daemon.example.test' }), + ); + + const connectFlags = { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example.test', + tenant: 'acme', + sessionIsolation: 'tenant' as const, + runId: 'run-123', + }; + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: connectFlags, + client: createTestClient(), + }); }); + const firstState = readActiveConnectionState({ stateDir }); + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: connectFlags, + client: createTestClient(), + }); + }); + const secondState = readActiveConnectionState({ stateDir }); + const storedSessions = fs + .readdirSync(path.join(stateDir, 'remote-connections')) + .filter((entry) => entry.endsWith('.json') && entry !== '.active-session.json'); + + assert.equal(secondState?.session, firstState?.session); + assert.equal(storedSessions.length, 1); + fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -241,7 +248,245 @@ test('connect missing scope errors mention remote config or flags', async () => fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('connect reuses an active compatible lease by heartbeat', async () => { +test('deferred materialization allocates lease and prepares Metro for open', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-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-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ + allocate: async (request) => ({ + leaseId: 'lease-new', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }), + prepare: async (options) => { + observedBridgeScope = options.bridgeScope; + return { + projectRoot: '/tmp/project', + kind: 'react-native', + dependenciesInstalled: false, + packageManager: null, + started: false, + reused: true, + pid: 0, + logPath: '/tmp/project/.agent-device/metro.log', + statusUrl: 'http://127.0.0.1:8081/status', + runtimeFilePath: null, + iosRuntime: { platform: 'ios' }, + androidRuntime: { platform: 'android', bundleUrl: 'https://bundle.example.test' }, + bridge: null, + }; + }, + }), + }); + + assert.equal(materialized.flags.leaseId, 'lease-new'); + assert.equal(materialized.flags.leaseBackend, 'android-instance'); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }); + assert.deepEqual(observedBridgeScope, { + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-new', + }); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId, + 'lease-new', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('deferred materialization prepares Metro for batch when a step opens an app', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-batch-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-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'batch', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + batchSteps: [{ command: 'open', positionals: ['com.example.demo'] }], + client: createTestClient(), + }); + + assert.equal(materialized.flags.leaseId, 'lease-1'); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', + }); + assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', + }); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('deferred materialization re-prepares runtime when explicit Metro overrides are provided', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-runtime-override-')); + 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-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-existing', + leaseBackend: 'android-instance', + platform: 'android', + runtime: { + platform: 'android', + bundleUrl: 'https://old-bundle.example.test', + }, + metro: { + projectRoot: '/tmp/project-old', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let prepareRequest: Parameters[0] | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroProjectRoot: '/tmp/project-new', + metroKind: 'expo', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + launchUrl: 'myapp://open', + }, + client: createTestClient({ + prepare: async (options) => { + prepareRequest = options; + return { + projectRoot: '/tmp/project-new', + kind: 'expo', + dependenciesInstalled: false, + packageManager: null, + started: false, + reused: false, + pid: 0, + logPath: '/tmp/project-new/.agent-device/metro.log', + statusUrl: 'http://127.0.0.1:8081/status', + runtimeFilePath: null, + iosRuntime: { platform: 'ios' }, + androidRuntime: { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android&dev=true', + }, + bridge: null, + }; + }, + }), + forceRuntimePrepare: true, + }); + + assert.equal(prepareRequest?.projectRoot, '/tmp/project-new'); + assert.equal(prepareRequest?.kind, 'expo'); + assert.equal(prepareRequest?.publicBaseUrl, 'https://sandbox.example.test'); + assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test'); + assert.equal(prepareRequest?.launchUrl, 'myapp://open'); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android&dev=true', + }); + assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android&dev=true', + }); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project-old', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('deferred materialization heartbeats an existing lease before dispatch', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-')); const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); @@ -256,7 +501,7 @@ test('connect reuses an active compatible lease by heartbeat', async () => { daemon: { baseUrl: 'https://daemon.example' }, tenant: 'acme', runId: 'run-123', - leaseId: 'lease-old', + leaseId: 'lease-existing', leaseBackend: 'android-instance', platform: 'android', connectedAt: new Date().toISOString(), @@ -264,48 +509,56 @@ test('connect reuses an active compatible lease by heartbeat', async () => { }, }); let heartbeatCount = 0; + let allocateCount = 0; - await captureStdout(async () => { - await connectCommand({ - positionals: [], - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - daemonBaseUrl: 'https://daemon.example', - tenant: 'acme', - runId: 'run-123', - session: 'adc-android', - platform: 'android', + const materialized = await materializeRemoteConnectionForCommand({ + command: 'apps', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + }, + client: createTestClient({ + heartbeat: async (request) => { + heartbeatCount += 1; + return { + leaseId: request.leaseId, + tenantId: request.tenant ?? 'acme', + runId: request.runId ?? 'run-123', + backend: request.leaseBackend ?? 'android-instance', + }; }, - client: createTestClient({ - heartbeat: async (request) => { - heartbeatCount += 1; - return { - leaseId: request.leaseId, - tenantId: request.tenant ?? 'acme', - runId: request.runId ?? 'run-123', - backend: 'android-instance', - }; - }, - allocate: async () => { - throw new Error('allocate should not run'); - }, - }), - }); + allocate: async (request) => { + allocateCount += 1; + return { + leaseId: 'lease-new', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }; + }, + }), }); assert.equal(heartbeatCount, 1); + assert.equal(allocateCount, 0); + assert.equal(materialized.flags.leaseId, 'lease-existing'); assert.equal( readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId, - 'lease-old', + 'lease-existing', ); + fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('connect allocates a new lease when cloud reports the stored lease is inactive', async () => { +test('deferred materialization reallocates when the persisted lease is inactive', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-stale-lease-')); const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); @@ -320,58 +573,53 @@ test('connect allocates a new lease when cloud reports the stored lease is inact daemon: { baseUrl: 'https://daemon.example' }, tenant: 'acme', runId: 'run-123', - leaseId: 'lease-old', + leaseId: 'lease-existing', leaseBackend: 'android-instance', platform: 'android', connectedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }); - let allocateCount = 0; - await captureStdout(async () => { - await connectCommand({ - positionals: [], - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - daemonBaseUrl: 'https://daemon.example', - tenant: 'acme', - runId: 'run-123', - session: 'adc-android', - platform: 'android', + const materialized = await materializeRemoteConnectionForCommand({ + command: 'apps', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + }, + client: createTestClient({ + heartbeat: async () => { + throw new AppError('UNAUTHORIZED', 'Lease is not active', { + reason: 'LEASE_NOT_FOUND', + }); }, - client: createTestClient({ - heartbeat: async () => { - throw new AppError('UNAUTHORIZED', 'Lease is not active', { - reason: 'LEASE_NOT_FOUND', - }); - }, - allocate: async (request) => { - allocateCount += 1; - return { - leaseId: 'lease-new', - tenantId: request.tenant, - runId: request.runId, - backend: request.leaseBackend ?? 'android-instance', - }; - }, + allocate: async (request) => ({ + leaseId: 'lease-new', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', }), - }); + }), }); - assert.equal(allocateCount, 1); + assert.equal(materialized.flags.leaseId, 'lease-new'); assert.equal( readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId, 'lease-new', ); + fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('connect does not allocate when heartbeat fails for auth or scope reasons', async () => { +test('deferred materialization preserves auth failures from lease allocation', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-auth-')); const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); @@ -386,8 +634,6 @@ test('connect does not allocate when heartbeat fails for auth or scope reasons', daemon: { baseUrl: 'https://daemon.example' }, tenant: 'acme', runId: 'run-123', - leaseId: 'lease-old', - leaseBackend: 'android-instance', platform: 'android', connectedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -396,8 +642,8 @@ test('connect does not allocate when heartbeat fails for auth or scope reasons', await assert.rejects( async () => - await connectCommand({ - positionals: [], + await materializeRemoteConnectionForCommand({ + command: 'apps', flags: { json: true, help: false, @@ -411,18 +657,137 @@ test('connect does not allocate when heartbeat fails for auth or scope reasons', platform: 'android', }, client: createTestClient({ - heartbeat: async () => { - throw new AppError('UNAUTHORIZED', 'Request rejected by auth hook', { + allocate: async () => { + throw new AppError('UNAUTHORIZED', 'Request rejected by auth hook.', { reason: 'AUTH_FAILED', }); }, - allocate: async () => { - throw new Error('allocate should not run'); - }, }), }), /Request rejected by auth hook/, ); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('deferred materialization does not require a lease backend for close', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-close-')); + 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-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'close', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + }, + client: createTestClient({ + allocate: async () => { + throw new Error('close should not allocate a lease'); + }, + heartbeat: async () => { + throw new Error('close should not heartbeat a lease'); + }, + }), + }); + + assert.equal(materialized.flags.leaseId, undefined); + assert.equal(materialized.flags.leaseBackend, undefined); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('deferred materialization stops the new Metro companion if state persistence fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-write-fail-')); + 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-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + platform: 'android', + metro: { + projectRoot: '/tmp/old-project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const originalWriteFileSync = fs.writeFileSync.bind(fs); + const writeFailure = new Error('state write failed'); + vi.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => { + if (String(file).endsWith(path.join('remote-connections', 'adc-android.json'))) { + throw writeFailure; + } + return originalWriteFileSync( + file as Parameters[0], + data as Parameters[1], + options as Parameters[2], + ); + }); + + await assert.rejects( + async () => + await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient(), + }), + writeFailure, + ); + + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -566,8 +931,6 @@ test('connect --force stops replaced Metro companion after state is updated', as runId: 'run-new', session: 'adc-android', platform: 'android', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', }, client: createTestClient({ release: async (request) => { @@ -590,46 +953,79 @@ test('connect --force stops replaced Metro companion after state is updated', as fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('connect cleans up prepared Metro companion if state write fails', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-fail-')); - const stateDir = path.join(tempRoot, 'state-file'); - const remoteConfigPath = path.join(tempRoot, 'remote.json'); - fs.writeFileSync(stateDir, 'not a directory'); - fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); - let releaseCount = 0; +test('connect --force without a session replaces the active generated connection', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-force-active-')); + const stateDir = path.join(tempRoot, '.state'); + const oldRemoteConfigPath = path.join(tempRoot, 'old-remote.json'); + const newRemoteConfigPath = path.join(tempRoot, 'new-remote.json'); + fs.writeFileSync(oldRemoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://old.example' })); + fs.writeFileSync(newRemoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://new.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-7f3a2c', + remoteConfigPath: oldRemoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(oldRemoteConfigPath), + tenant: 'acme', + runId: 'run-old', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + daemon: { + baseUrl: 'https://old.example', + transport: 'http', + }, + metro: { + projectRoot: '/tmp/old-project', + profileKey: oldRemoteConfigPath, + consumerKey: 'adc-7f3a2c', + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let releaseRequest: Parameters[0] | undefined; - await assert.rejects( - async () => - await connectCommand({ - positionals: [], - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - daemonBaseUrl: 'https://daemon.example', - tenant: 'acme', - runId: 'run-123', - platform: 'android', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + force: true, + stateDir, + remoteConfig: newRemoteConfigPath, + daemonBaseUrl: 'https://new.example', + tenant: 'acme', + runId: 'run-new', + platform: 'android', + }, + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; }, - client: createTestClient({ - release: async () => { - releaseCount += 1; - return { released: true }; - }, - }), }), - ); + }); + }); + + const activeState = readActiveConnectionState({ stateDir }); + const storedSessions = fs + .readdirSync(path.join(stateDir, 'remote-connections')) + .filter((entry) => entry.endsWith('.json') && entry !== '.active-session.json'); - assert.equal(releaseCount, 1); + assert.equal(activeState?.session, 'adc-7f3a2c'); + assert.equal(activeState?.runId, 'run-new'); + assert.equal(activeState?.remoteConfigPath, newRemoteConfigPath); + assert.equal(releaseRequest?.leaseId, 'lease-old'); assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - profileKey: remoteConfigPath, - consumerKey: 'default', + projectRoot: '/tmp/old-project', + profileKey: oldRemoteConfigPath, + consumerKey: 'adc-7f3a2c', }); + assert.equal(storedSessions.length, 1); + fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli.ts b/src/cli.ts index c8cdfa3c1..598c76bc0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { type AgentDeviceClientConfig, type AgentDeviceDaemonTransport, } from './client.ts'; +import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; import { createRequestId, @@ -36,6 +37,23 @@ const DEFAULT_CLI_DEPS: CliDeps = { sendToDaemon, }; +const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set([ + 'launchUrl', + 'metroBearerToken', + 'metroKind', + 'metroListenHost', + 'metroNoInstallDeps', + 'metroNoReuseExisting', + 'metroPreparePort', + 'metroProbeTimeoutMs', + 'metroProjectRoot', + 'metroProxyBaseUrl', + 'metroPublicBaseUrl', + 'metroRuntimeFile', + 'metroStartupTimeoutMs', + 'metroStatusHost', +]); + export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise { const requestId = createRequestId(); const version = readVersion(); @@ -166,45 +184,76 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): process.exit(1); return; } - maybeRunUpgradeNotifier({ - command, - currentVersion: version, - stateDir: daemonPaths.baseDir, - flags: effectiveFlags, - }); - const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl; - const logTailStopper = - effectiveFlags.verbose && !effectiveFlags.json && !remoteDaemonBaseUrl - ? startDaemonLogTail(daemonPaths.logPath) - : null; - const clientConfig: AgentDeviceClientConfig = { - session: effectiveFlags.session ?? sessionName, - requestId, - stateDir: effectiveFlags.stateDir, - daemonBaseUrl: effectiveFlags.daemonBaseUrl, - daemonAuthToken: effectiveFlags.daemonAuthToken, - daemonTransport: effectiveFlags.daemonTransport, - daemonServerMode: effectiveFlags.daemonServerMode, - tenant: effectiveFlags.tenant, - sessionIsolation: effectiveFlags.sessionIsolation, - runId: effectiveFlags.runId, - leaseId: effectiveFlags.leaseId, - leaseBackend: effectiveFlags.leaseBackend, - runtime: connectionDefaults?.runtime, - lockPolicy: binding.lockPolicy, - lockPlatform: binding.defaultPlatform, - cwd: process.cwd(), - debug: Boolean(effectiveFlags.verbose), - }; - const client = createAgentDeviceClient(clientConfig, { - transport: deps.sendToDaemon as AgentDeviceDaemonTransport, - }); + let logTailStopper: (() => void) | null = null; try { + maybeRunUpgradeNotifier({ + command, + currentVersion: version, + stateDir: daemonPaths.baseDir, + flags: effectiveFlags, + }); + let resolvedRuntime = connectionDefaults?.runtime; + const buildClientConfig = ( + currentFlags: CliFlags, + runtime: SessionRuntimeHints | undefined, + ): AgentDeviceClientConfig => ({ + session: currentFlags.session ?? sessionName, + requestId, + stateDir: currentFlags.stateDir, + daemonBaseUrl: currentFlags.daemonBaseUrl, + daemonAuthToken: currentFlags.daemonAuthToken, + daemonTransport: currentFlags.daemonTransport, + daemonServerMode: currentFlags.daemonServerMode, + tenant: currentFlags.tenant, + sessionIsolation: currentFlags.sessionIsolation, + runId: currentFlags.runId, + leaseId: currentFlags.leaseId, + leaseBackend: currentFlags.leaseBackend, + runtime, + lockPolicy: binding.lockPolicy, + lockPlatform: binding.defaultPlatform, + cwd: process.cwd(), + debug: Boolean(currentFlags.verbose), + }); + let parsedBatchSteps: BatchStep[] | undefined; if (command === 'batch') { if (positionals.length > 0) { throw new AppError('INVALID_ARGS', 'batch does not accept positional arguments.'); } - const batchSteps = readBatchSteps(flags).map((step, _index) => ({ + parsedBatchSteps = readBatchSteps(flags); + } + + if (connectionDefaults && command !== 'connect' && command !== 'connection') { + const materializationClient = createAgentDeviceClient( + buildClientConfig(effectiveFlags, resolvedRuntime), + { + transport: deps.sendToDaemon as AgentDeviceDaemonTransport, + }, + ); + const materialized = await materializeRemoteConnectionForCommand({ + command, + flags: effectiveFlags, + client: materializationClient, + runtime: resolvedRuntime, + batchSteps: parsedBatchSteps, + forceRuntimePrepare: hasExplicitMetroRuntimeOverrides(explicitFlagKeys), + }); + effectiveFlags = materialized.flags; + resolvedRuntime = materialized.runtime; + } + const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl; + logTailStopper = + effectiveFlags.verbose && !effectiveFlags.json && !remoteDaemonBaseUrl + ? startDaemonLogTail(daemonPaths.logPath) + : null; + const client = createAgentDeviceClient(buildClientConfig(effectiveFlags, resolvedRuntime), { + transport: deps.sendToDaemon as AgentDeviceDaemonTransport, + }); + if (command === 'batch') { + if (!parsedBatchSteps) { + throw new AppError('INVALID_ARGS', 'batch requires --steps or --steps-file.'); + } + const batchSteps = parsedBatchSteps.map((step, _index) => ({ ...step, flags: binding.lockPolicy && flags.platform === undefined @@ -343,6 +392,15 @@ function mergeConnectionFlags( return merged; } +function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boolean { + for (const key of METRO_RUNTIME_OVERRIDE_FLAG_KEYS) { + if (explicitFlagKeys.has(key)) { + return true; + } + } + return false; +} + function guessSessionFromArgv(argv: string[]): string | null { for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts new file mode 100644 index 000000000..0b91470cd --- /dev/null +++ b/src/cli/commands/connection-runtime.ts @@ -0,0 +1,377 @@ +import { resolveDaemonPaths } from '../../daemon/config.ts'; +import { stopMetroTunnel } from '../../metro.ts'; +import { + readRemoteConnectionState, + writeRemoteConnectionState, + type RemoteConnectionState, +} from '../../remote-connection-state.ts'; +import type { BatchStep } from '../../core/dispatch.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import type { AgentDeviceClient, Lease } from '../../client.ts'; + +const leaseDeferredCommands = new Set([ + 'connect', + 'connection', + 'close', + 'devices', + 'disconnect', + 'ensure-simulator', + 'metro', + 'session', +]); +const runtimeDeferredCommands = new Set(['open']); + +export async function materializeRemoteConnectionForCommand(options: { + command: string; + flags: CliFlags; + client: AgentDeviceClient; + runtime?: SessionRuntimeHints; + batchSteps?: BatchStep[]; + forceRuntimePrepare?: boolean; +}): Promise<{ flags: CliFlags; runtime?: SessionRuntimeHints }> { + const { command, flags, client } = options; + if (!flags.remoteConfig) { + return { flags, runtime: options.runtime }; + } + + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); + if (!state) { + return { flags, runtime: options.runtime }; + } + + const nextFlags = { ...flags }; + let nextRuntime = selectCompatibleRuntime(state.runtime, flags.platform) ?? options.runtime; + let nextState = state; + let changed = false; + 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; + } + } + + if ( + shouldPrepareRuntimeForCommand(command, options.batchSteps) && + hasDeferredMetroConfig(flags) + ) { + 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; + } + } + + if (changed) { + try { + writeRemoteConnectionState({ stateDir, state: nextState }); + } catch (error) { + await stopMetroCleanup(preparedMetroCleanupOnFailure); + throw error; + } + } + await stopMetroCleanup(metroCleanupToStop); + + return { + flags: { + ...nextFlags, + session: nextState.session, + leaseId: nextState.leaseId, + leaseBackend: nextState.leaseBackend, + platform: nextState.platform ?? nextFlags.platform, + target: nextState.target ?? nextFlags.target, + }, + runtime: nextRuntime, + }; +} + +export async function prepareConnectedMetro( + flags: CliFlags, + client: AgentDeviceClient, + remoteConfigPath: string, + session: string, + bridgeScope: { + tenantId: string; + runId: string; + leaseId: string; + }, +): Promise<{ + runtime?: SessionRuntimeHints; + cleanup?: NonNullable; +}> { + if (!flags.metroProjectRoot && !flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { + return {}; + } + if (flags.platform !== 'ios' && flags.platform !== 'android') { + throw new AppError( + 'INVALID_ARGS', + 'Deferred Metro preparation requires platform "ios" or "android".', + ); + } + if (!flags.metroPublicBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'Deferred Metro preparation requires metroPublicBaseUrl when Metro settings are provided.', + ); + } + const prepared = await client.metro.prepare({ + projectRoot: flags.metroProjectRoot, + kind: flags.metroKind, + publicBaseUrl: flags.metroPublicBaseUrl, + proxyBaseUrl: flags.metroProxyBaseUrl, + bearerToken: flags.metroBearerToken, + bridgeScope, + launchUrl: flags.launchUrl, + companionProfileKey: remoteConfigPath, + companionConsumerKey: session, + port: flags.metroPreparePort, + listenHost: flags.metroListenHost, + statusHost: flags.metroStatusHost, + startupTimeoutMs: flags.metroStartupTimeoutMs, + probeTimeoutMs: flags.metroProbeTimeoutMs, + reuseExisting: flags.metroNoReuseExisting ? false : undefined, + installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, + runtimeFilePath: flags.metroRuntimeFile, + }); + return { + runtime: flags.platform === 'ios' ? prepared.iosRuntime : prepared.androidRuntime, + cleanup: flags.metroProxyBaseUrl + ? { + projectRoot: prepared.projectRoot, + profileKey: remoteConfigPath, + consumerKey: session, + } + : undefined, + }; +} + +export async function stopMetroCleanup( + cleanup: RemoteConnectionState['metro'] | undefined, +): Promise { + if (!cleanup) return; + try { + await stopMetroTunnel(cleanup); + } catch { + // Connection lifecycle cleanup must stay best-effort. + } +} + +export async function releasePreviousLease( + client: AgentDeviceClient, + previous: RemoteConnectionState, +): Promise { + if (!previous.leaseId) 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, + }); + } catch { + // Reconnect must succeed even if the old lease was already released. + } +} + +export function resolveRequestedLeaseBackend(flags: CliFlags): LeaseBackend | undefined { + if (flags.leaseBackend) return flags.leaseBackend; + if (flags.platform === 'android') return 'android-instance'; + if (flags.platform === 'ios') return 'ios-instance'; + return undefined; +} + +function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBackend { + const leaseBackend = resolveRequestedLeaseBackend(flags); + if (leaseBackend) return leaseBackend; + throw new AppError( + 'INVALID_ARGS', + `${command} requires --platform ios|android or --lease-backend when the remote connection has not resolved a lease yet.`, + ); +} + +function shouldAllocateLeaseForCommand(command: string): boolean { + return !leaseDeferredCommands.has(command); +} + +function shouldPrepareRuntimeForCommand(command: string, batchSteps?: BatchStep[]): boolean { + if (runtimeDeferredCommands.has(command)) { + return true; + } + if (command !== 'batch' || !batchSteps) { + return false; + } + return batchSteps.some((step) => { + const stepCommand = step.command.trim().toLowerCase(); + return runtimeDeferredCommands.has(stepCommand) && step.runtime === undefined; + }); +} + +function hasDeferredMetroConfig(flags: CliFlags): boolean { + return Boolean( + flags.metroPublicBaseUrl || + flags.metroProxyBaseUrl || + flags.metroProjectRoot || + flags.metroKind, + ); +} + +function isRuntimeCompatibleWithPlatform( + runtime: SessionRuntimeHints, + platform: CliFlags['platform'], +): boolean { + if (!runtime.platform || !platform || (platform !== 'ios' && platform !== 'android')) { + return true; + } + return runtime.platform === platform; +} + +function isSameMetroCleanup( + left: RemoteConnectionState['metro'] | undefined, + right: RemoteConnectionState['metro'] | undefined, +): boolean { + return ( + left?.projectRoot === right?.projectRoot && + left?.profileKey === right?.profileKey && + left?.consumerKey === right?.consumerKey + ); +} + +function selectCompatibleRuntime( + runtime: SessionRuntimeHints | undefined, + platform: CliFlags['platform'], +): SessionRuntimeHints | undefined { + if (!runtime) return undefined; + return isRuntimeCompatibleWithPlatform(runtime, platform) ? runtime : undefined; +} + +async function allocateOrReuseLease( + client: AgentDeviceClient, + state: RemoteConnectionState, + leaseBackend: LeaseBackend, +): Promise { + if (state.leaseId && state.leaseBackend === leaseBackend) { + const existing = await heartbeatOrAllocateLease(client, state.leaseId, { + tenant: state.tenant, + runId: state.runId, + leaseBackend, + }); + if (existing) return existing; + } + return await client.leases.allocate({ + tenant: state.tenant, + runId: state.runId, + leaseBackend, + }); +} + +function assertRequestedConnectionScope( + state: RemoteConnectionState, + flags: CliFlags, + requestedLeaseBackend: LeaseBackend, +): void { + if (state.leaseBackend && state.leaseBackend !== requestedLeaseBackend) { + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a different lease backend. Re-run connect --force to replace it.', + { session: state.session, leaseBackend: state.leaseBackend }, + ); + } + if (state.platform && flags.platform && state.platform !== flags.platform) { + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', + { session: state.session, platform: state.platform }, + ); + } + if (state.target && flags.target && state.target !== flags.target) { + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a different target. Re-run connect --force to replace it.', + { session: state.session, target: state.target }, + ); + } +} + +async function heartbeatOrAllocateLease( + client: AgentDeviceClient, + leaseId: string, + scope: { tenant: string; runId: string; leaseBackend: LeaseBackend }, +): Promise { + try { + return await client.leases.heartbeat({ + tenant: scope.tenant, + runId: scope.runId, + leaseId, + leaseBackend: scope.leaseBackend, + }); + } catch (error) { + if (isInactiveLeaseError(error)) return undefined; + throw error; + } +} + +function isInactiveLeaseError(error: unknown): boolean { + if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; + return ( + error.details?.reason === 'LEASE_NOT_FOUND' || + error.details?.reason === 'LEASE_EXPIRED' || + error.details?.reason === 'LEASE_REVOKED' + ); +} diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index be03daca0..8b32c7693 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,5 +1,5 @@ +import crypto from 'node:crypto'; import { resolveDaemonPaths } from '../../daemon/config.ts'; -import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; import { fingerprint, @@ -11,17 +11,20 @@ import { type RemoteConnectionState, } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; +import { + releasePreviousLease, + resolveRequestedLeaseBackend, + stopMetroCleanup, +} from './connection-runtime.ts'; import { writeCommandOutput } from './shared.ts'; -import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; +import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; -import type { AgentDeviceClient, Lease } from '../../client.ts'; import type { ClientCommandHandler } from './router.ts'; export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { if (!flags.remoteConfig) { throw new AppError('INVALID_ARGS', 'connect requires --remote-config .'); } - const session = flags.session ?? 'default'; const tenant = flags.tenant; const runId = flags.runId; if (!tenant) { @@ -43,23 +46,28 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => ); } + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const activeState = flags.session ? null : readActiveConnectionState({ stateDir }); + const session = flags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, cwd: process.cwd(), env: process.env, }); const remoteConfigHash = hashRemoteConfigFile(remoteConfig.resolvedPath); - const leaseBackend = flags.leaseBackend ?? inferLeaseBackend(flags.platform); const daemon = buildDaemonState(flags); - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const previous = readRemoteConnectionState({ stateDir, session }); + const previous = + activeState?.session === session + ? activeState + : readRemoteConnectionState({ stateDir, session }); if ( previous && !isCompatibleConnection(previous, { flags, + session, remoteConfigPath: remoteConfig.resolvedPath, remoteConfigHash, - leaseBackend, + desiredLeaseBackend: resolveRequestedLeaseBackend(flags), daemon, }) ) { @@ -72,85 +80,39 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => } } - let lease: Lease | undefined; - let allocatedForThisCommand = false; - let metroCleanup: NonNullable | undefined; - let statePersisted = false; - try { - lease = - previous && previous.leaseId && !flags.force - ? await heartbeatOrAllocateLease(client, previous.leaseId, { tenant, runId, leaseBackend }) - : undefined; - if (!lease) { - lease = await client.leases.allocate({ tenant, runId, leaseBackend }); - allocatedForThisCommand = true; - } - - const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session, { - tenantId: lease.tenantId, - runId: lease.runId, - leaseId: lease.leaseId, - }); - metroCleanup = metro.cleanup; - const now = new Date().toISOString(); - const state: RemoteConnectionState = { - version: 1, - session, - remoteConfigPath: remoteConfig.resolvedPath, - remoteConfigHash, - daemon, - tenant, - runId, - leaseId: lease.leaseId, - leaseBackend, - platform: flags.platform, - target: flags.target, - runtime: metro.runtime, - metro: metroCleanup, - connectedAt: previous && !flags.force ? previous.connectedAt : now, - updatedAt: now, - }; - writeRemoteConnectionState({ stateDir, state }); - statePersisted = true; - if ( - previous && - flags.force && - (previous.metro?.projectRoot !== metroCleanup?.projectRoot || - previous.metro?.profileKey !== metroCleanup?.profileKey || - previous.metro?.consumerKey !== metroCleanup?.consumerKey) - ) { - await stopMetroCleanup(previous.metro); - } - if ( - previous && - flags.force && - (previous.tenant !== state.tenant || - previous.runId !== state.runId || - previous.leaseId !== state.leaseId || - !isSameDaemonState(previous.daemon, state.daemon)) - ) { - await releasePreviousLease(client, previous); - } - - writeCommandOutput( - flags, - serializeConnectionState(state), - () => - `Connected remote session "${session}" tenant "${tenant}" run "${runId}" lease ${state.leaseId}`, - ); - } catch (error) { - if (!statePersisted) { - await stopMetroCleanup(metroCleanup); - } - if (allocatedForThisCommand && lease) { - try { - await client.leases.release({ tenant, runId, leaseId: lease.leaseId }); - } catch { - // Best-effort cleanup; preserve the original connection failure. - } - } - throw error; + const now = new Date().toISOString(); + const state: RemoteConnectionState = { + version: 1, + session, + remoteConfigPath: remoteConfig.resolvedPath, + remoteConfigHash, + daemon, + tenant, + runId, + leaseId: previous && !flags.force ? previous.leaseId : undefined, + leaseBackend: + previous && !flags.force ? previous.leaseBackend : resolveRequestedLeaseBackend(flags), + platform: flags.platform ?? (previous && !flags.force ? previous.platform : undefined), + target: flags.target ?? (previous && !flags.force ? previous.target : undefined), + runtime: previous && !flags.force ? previous.runtime : undefined, + metro: previous && !flags.force ? previous.metro : undefined, + connectedAt: previous && !flags.force ? previous.connectedAt : now, + updatedAt: now, + }; + writeRemoteConnectionState({ stateDir, state }); + if (previous && flags.force) { + await stopMetroCleanup(previous.metro); + await releasePreviousLease(client, previous); } + + writeCommandOutput( + flags, + serializeConnectionState(state), + () => + `Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${ + state.leaseId ? `lease ${state.leaseId}` : 'lease pending' + }`, + ); return true; }; @@ -177,15 +139,17 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) } await stopMetroCleanup(state.metro); let released = false; - try { - const result = await client.leases.release({ - tenant: state.tenant, - runId: state.runId, - leaseId: state.leaseId, - }); - released = result.released; - } catch { - // Bridges may release on close or be unreachable; local state still needs cleanup. + if (state.leaseId) { + try { + const result = await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: state.leaseId, + }); + released = result.released; + } catch { + // Bridges may release on close or be unreachable; local state still needs cleanup. + } } removeRemoteConnectionState({ stateDir, session: connectedSession }); writeCommandOutput( @@ -216,7 +180,7 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla writeCommandOutput(flags, serializeConnectionState(state), () => [ `Connected remote session "${state.session}".`, - `tenant=${state.tenant} runId=${state.runId} leaseId=${state.leaseId} backend=${state.leaseBackend}`, + `tenant=${state.tenant} runId=${state.runId} leaseId=${state.leaseId ?? 'pending'} backend=${state.leaseBackend ?? 'pending'}`, `remoteConfig=${state.remoteConfigPath}`, state.runtime ? 'metro=prepared' : 'metro=not-prepared', ].join('\n'), @@ -224,138 +188,37 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla return true; }; -async function heartbeatOrAllocateLease( - client: AgentDeviceClient, - leaseId: string, - scope: { tenant: string; runId: string; leaseBackend: LeaseBackend }, -): Promise { - try { - return await client.leases.heartbeat({ - tenant: scope.tenant, - runId: scope.runId, - leaseId, - leaseBackend: scope.leaseBackend, - }); - } catch (error) { - if (isInactiveLeaseError(error)) return undefined; - throw error; - } -} - -async function prepareConnectedMetro( - flags: CliFlags, - client: AgentDeviceClient, - remoteConfigPath: string, - session: string, - bridgeScope: { - tenantId: string; - runId: string; - leaseId: string; - }, -): Promise<{ - runtime?: SessionRuntimeHints; - cleanup?: NonNullable; -}> { - if (!flags.metroProjectRoot && !flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { - return {}; - } - if (flags.platform !== 'ios' && flags.platform !== 'android') { - throw new AppError( - 'INVALID_ARGS', - 'connect Metro preparation requires platform "ios" or "android".', - ); - } - if (!flags.metroPublicBaseUrl) { - throw new AppError('INVALID_ARGS', 'connect Metro preparation requires metroPublicBaseUrl.'); - } - const prepared = await client.metro.prepare({ - projectRoot: flags.metroProjectRoot, - kind: flags.metroKind, - publicBaseUrl: flags.metroPublicBaseUrl, - proxyBaseUrl: flags.metroProxyBaseUrl, - bearerToken: flags.metroBearerToken, - bridgeScope, - launchUrl: flags.launchUrl, - companionProfileKey: remoteConfigPath, - companionConsumerKey: session, - port: flags.metroPreparePort, - listenHost: flags.metroListenHost, - statusHost: flags.metroStatusHost, - startupTimeoutMs: flags.metroStartupTimeoutMs, - probeTimeoutMs: flags.metroProbeTimeoutMs, - reuseExisting: flags.metroNoReuseExisting ? false : undefined, - installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, - runtimeFilePath: flags.metroRuntimeFile, - }); - return { - runtime: flags.platform === 'ios' ? prepared.iosRuntime : prepared.androidRuntime, - cleanup: flags.metroProxyBaseUrl - ? { - projectRoot: prepared.projectRoot, - profileKey: remoteConfigPath, - consumerKey: session, - } - : undefined, - }; -} - -async function stopMetroCleanup( - cleanup: RemoteConnectionState['metro'] | undefined, -): Promise { - if (!cleanup) return; - try { - await stopMetroTunnel(cleanup); - } catch { - // Connection lifecycle cleanup must stay best-effort. - } -} - -async function releasePreviousLease( - client: AgentDeviceClient, - previous: RemoteConnectionState, -): Promise { - 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, - }); - } catch { - // Reconnect must succeed even if the old lease was already released. +function createRemoteSessionName(stateDir: string): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = `adc-${crypto.randomBytes(3).toString('hex')}`; + if (!readRemoteConnectionState({ stateDir, session: candidate })) { + return candidate; + } } -} - -function inferLeaseBackend(platform: CliFlags['platform']): LeaseBackend { - if (platform === 'android') return 'android-instance'; - if (platform === 'ios') return 'ios-instance'; - throw new AppError( - 'INVALID_ARGS', - 'connect requires --lease-backend when platform is not ios or android.', - ); + return `adc-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; } function isCompatibleConnection( state: RemoteConnectionState, options: { flags: CliFlags; + session: string; remoteConfigPath: string; remoteConfigHash: string; - leaseBackend: LeaseBackend; + desiredLeaseBackend?: LeaseBackend; daemon: RemoteConnectionState['daemon']; }, ): boolean { return ( state.remoteConfigPath === options.remoteConfigPath && state.remoteConfigHash === options.remoteConfigHash && - state.session === (options.flags.session ?? 'default') && + state.session === options.session && state.tenant === options.flags.tenant && state.runId === options.flags.runId && - state.leaseBackend === options.leaseBackend && - state.platform === options.flags.platform && - state.target === options.flags.target && + (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) && isSameDaemonState(state.daemon, options.daemon) ); } @@ -392,21 +255,13 @@ function sanitizeDaemonBaseUrl(value: string | undefined): string | undefined { return url.toString().replace(/\/+$/, ''); } -function isInactiveLeaseError(error: unknown): boolean { - if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; - return ( - error.details?.reason === 'LEASE_NOT_FOUND' || - error.details?.reason === 'LEASE_EXPIRED' || - error.details?.reason === 'LEASE_REVOKED' - ); -} - function serializeConnectionState(state: RemoteConnectionState): Record { return { connected: true, session: state.session, tenant: state.tenant, runId: state.runId, + leaseAllocated: Boolean(state.leaseId), leaseId: state.leaseId, leaseBackend: state.leaseBackend, platform: state.platform, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index af979a763..7e5009adc 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -515,6 +515,58 @@ test('batch step forwards typed runtime payload', async () => { ]); }); +test('batch step inherits parent runtime unless the step overrides it', async () => { + const sessionStore = makeSessionStore(); + const seenRuntimes: Array = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + runtime: { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }, + flags: { + batchSteps: [ + { + command: 'open', + positionals: ['Demo'], + }, + { + command: 'open', + positionals: ['Demo'], + runtime: { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + }, + ], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + seenRuntimes.push(stepReq.runtime); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(seenRuntimes).toEqual([ + { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }, + { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + ]); +}); + test('batch step pins nested requests to the resolved session', async () => { const sessionStore = makeSessionStore(); const seenSessions: Array<{ session: string; flagSession: string | undefined }> = []; diff --git a/src/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 0eb135eae..46025af4f 100644 --- a/src/daemon/handlers/session-batch.ts +++ b/src/daemon/handlers/session-batch.ts @@ -103,7 +103,7 @@ async function runBatchStep( command: step.command, positionals: step.positionals, flags: stepFlags, - runtime: step.runtime as DaemonRequest['runtime'], + runtime: (step.runtime === undefined ? req.runtime : step.runtime) as DaemonRequest['runtime'], meta: req.meta, }); const durationMs = Date.now() - stepStartedAt; diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index 1b0cb2047..b10b02c9d 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -19,8 +19,8 @@ export type RemoteConnectionState = { }; tenant: string; runId: string; - leaseId: string; - leaseBackend: LeaseBackend; + leaseId?: string; + leaseBackend?: LeaseBackend; platform?: CliFlags['platform']; target?: CliFlags['target']; runtime?: SessionRuntimeHints; @@ -244,8 +244,8 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState !Array.isArray(record.daemon))) && typeof record.tenant === 'string' && typeof record.runId === 'string' && - typeof record.leaseId === 'string' && - typeof record.leaseBackend === 'string' && + (record.leaseId === undefined || typeof record.leaseId === 'string') && + (record.leaseBackend === undefined || typeof record.leaseBackend === 'string') && typeof record.connectedAt === 'string' && typeof record.updatedAt === 'string' ); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index aa38d9872..ab44b5d8a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -663,8 +663,9 @@ agent-device snapshot -i agent-device disconnect ``` -- `--remote-config ` points to a remote workflow profile that captures stable host, tenant/run, session, platform, lease backend, and Metro settings for `connect`. -- `connect --remote-config ...` is the main agent flow. It allocates or refreshes a tenant lease, prepares Metro locally, auto-manages the local Metro companion when the bridge needs it, and stores runtime hints for later `open`. +- `--remote-config ` points to a remote workflow profile that captures stable host, tenant/run, and any optional session, platform, lease backend, or Metro overrides for `connect`. +- `connect --remote-config ...` is the main agent flow. It generates a local session name when needed, stores the remote scope locally, and defers tenant lease allocation plus Metro preparation until a later command needs them. +- Deferred Metro preparation also applies to `batch` when any step opens an app and the batch does not provide its own per-step runtime. - After `connect`, `snapshot`, `press`, `fill`, `screenshot`, and other normal commands reuse active connection state so agents do not repeat remote host/session/lease selectors inline. Explicit command-line flags override those connected defaults. - `metro prepare --remote-config ...` remains an advanced inspection/debug path and can still write a `--runtime-file ` artifact when needed. - The local Metro companion runs on the same machine as the React Native project and Metro. `disconnect` stops the companion owned by the connection, but it does not stop the user’s Metro server.