From 04f53bbc04a6e6379e9d845e39b336e6601fa860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 07:59:47 +0200 Subject: [PATCH 1/3] docs: clarify agent-device help entrypoint --- src/utils/__tests__/args.test.ts | 47 +++++++++++++++++++++++---- src/utils/cli-help.ts | 56 ++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..95349d3ce 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1177,10 +1177,35 @@ test('usage includes only global flags in the top-level global flags section', ( test('usage includes agent workflows, config, environment, and examples footers', () => { const usageText = usage(); + assert.match( + usageText, + /CLI to automate supported app, device, desktop, and web targets for AI agents/, + ); assert.ok( usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'), 'Agent workflows should appear before the command list for agents that only read the top of help.', ); + assert.ok( + usageText.indexOf('Agent Starting Point:') < usageText.indexOf('Agent Workflows:'), + 'The agent starting point should appear before topic selection.', + ); + assert.match(usageText, /Agent Starting Point:/); + assert.match( + usageText, + /agent-device is the default automation surface for app\/device workflows across supported targets/, + ); + assert.match( + usageText, + /Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network\/perf evidence, and verification/, + ); + assert.match( + usageText, + /Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step/, + ); + assert.match( + usageText, + /Start with agent-device help workflow to understand the core loop and how to use the tool/, + ); assert.match(usageText, /Agent Quickstart:/); assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/); assert.match(usageText, /Use selectors or refs as positional targets/); @@ -1225,21 +1250,30 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /Full operating guide: agent-device help workflow/); assert.match(usageText, /Exploratory QA: agent-device help dogfood/); assert.match(usageText, /Agent Workflows:/); - assert.match(usageText, /help workflow\s+Normal bootstrap, exploration, and validation loop/); - assert.match(usageText, /help debugging\s+Logs, network, perf memory, and traces/); assert.match( usageText, - /help react-devtools\s+React Native performance, profiling, component tree, and renders/, + /agent-device help workflow\s+Start here for the core loop, command shape, refs\/selectors, and verification/, + ); + assert.match( + usageText, + /agent-device help debugging\s+Use when logs, network, perf memory, traces, alerts, or diagnostics matter/, + ); + assert.match( + usageText, + /agent-device help react-devtools\s+Use when inspecting components, props\/state\/hooks, renders, or profiles/, + ); + assert.match( + usageText, + /agent-device help physical-device\s+Use when using a connected phone\/tablet or iOS signing setup/, ); assert.match( usageText, - /help physical-device\s+Connected phone\/tablet setup and iOS signing prerequisites/, + /agent-device help react-native\s+Use when the target app is React Native, Expo, or a dev client/, ); assert.match( usageText, - /help react-native\s+React Native app automation hazards, overlays, Metro, and routing/, + /agent-device help web\s+Use when automating a browser through agent-device sessions/, ); - assert.match(usageText, /help web\s+Minimal browser sessions through agent-browser/); assert.match(usageText, /Configuration:/); assert.match( usageText, @@ -1663,6 +1697,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /For app\/package launches, run metro prepare/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..7b5117d63 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -10,31 +10,50 @@ import { } from './command-schema.ts'; const AGENT_WORKFLOWS = [ - { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, perf memory, and traces' }, { - label: 'help react-native', - description: 'React Native app automation hazards, overlays, Metro, and routing', + label: 'agent-device help workflow', + description: 'Start here for the core loop, command shape, refs/selectors, and verification', }, { - label: 'help react-devtools', - description: 'React Native performance, profiling, component tree, and renders', + label: 'agent-device help debugging', + description: 'Use when logs, network, perf memory, traces, alerts, or diagnostics matter', }, { - label: 'help cdp', - description: 'React Native CDP targets, JS heap snapshots, and leak triage', + label: 'agent-device help react-native', + description: 'Use when the target app is React Native, Expo, or a dev client', }, { - label: 'help physical-device', - description: 'Connected phone/tablet setup and iOS signing prerequisites', + label: 'agent-device help react-devtools', + description: 'Use when inspecting components, props/state/hooks, renders, or profiles', }, { - label: 'help remote', - description: 'Remote/cloud config, tenants, leases, and local service tunnels', + label: 'agent-device help cdp', + description: 'Use when investigating JS heap growth, heap snapshots, or retainers', }, - { label: 'help web', description: 'Minimal browser sessions through agent-browser' }, - { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, - { label: 'help dogfood', description: 'Exploratory QA report workflow' }, + { + label: 'agent-device help physical-device', + description: 'Use when using a connected phone/tablet or iOS signing setup', + }, + { + label: 'agent-device help remote', + description: 'Use when working through cloud config, tenants, leases, or local tunnels', + }, + { + label: 'agent-device help web', + description: 'Use when automating a browser through agent-device sessions', + }, + { + label: 'agent-device help macos', + description: 'Use when targeting desktop, frontmost app, or menu bar surfaces', + }, + { label: 'agent-device help dogfood', description: 'Use when producing exploratory QA evidence' }, +] as const; + +const AGENT_START_LINES = [ + 'agent-device is the default automation surface for app/device workflows across supported targets.', + 'Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network/perf evidence, and verification.', + 'Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step.', + 'Start with agent-device help workflow to understand the core loop and how to use the tool.', ] as const; const AGENT_QUICKSTART_LINES = [ @@ -536,7 +555,7 @@ React Native dev loop: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach local Metro. Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. Multiple local worktrees can reuse one native iOS simulator build by running each worktree's Metro on a different port and opening the same installed app on different simulators with explicit runtime hints: @@ -863,7 +882,7 @@ function buildCommandListUsage(commandName: string, schema: CommandSchema): stri function renderUsageText(): string { const header = `agent-device [args] [--json] -CLI to control iOS and Android devices for AI agents. +CLI to automate supported app, device, desktop, and web targets for AI agents. `; const commands = listCliCommandNames().map((name) => { @@ -878,6 +897,7 @@ CLI to control iOS and Android devices for AI agents. const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); const flagsSection = renderFlagSection('Global Flags:', helpFlags); + const startSection = renderTextSection('Agent Starting Point:', AGENT_START_LINES); const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); @@ -885,6 +905,8 @@ CLI to control iOS and Android devices for AI agents. const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); return `${header} +${startSection} + ${workflowsSection} ${commandLines} From c593ecb04d1a8c835b9e096c8a71c3a1f73dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:10:03 +0200 Subject: [PATCH 2/3] docs: trim duplicated help guidance --- src/utils/cli-help.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 7b5117d63..3898d0e7a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -88,7 +88,6 @@ const AGENT_QUICKSTART_LINES = [ 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', 'Debug evidence: Session state contains request diagnostics and runner.log; use logs clear --restart/mark/path, trace, and network dump --include headers for app evidence.', - 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', ] as const; From e98ec1655a40c67653270de819e4b45a7894f1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:50:01 +0200 Subject: [PATCH 3/3] feat: add proxy lease runner diagnostics --- src/core/dispatch-context.ts | 2 + src/core/dispatch.ts | 1 + src/core/interactor-types.ts | 3 + src/core/runner-lease-context.ts | 8 ++ src/daemon-runtime.ts | 5 + src/daemon/context.ts | 4 + src/daemon/handlers/session-open.ts | 8 ++ src/daemon/lease-context.ts | 21 ++++ src/daemon/request-execution-scope.ts | 6 +- .../ios/__tests__/runner-session.test.ts | 106 ++++++++++++++++++ src/platforms/ios/interactions.ts | 1 + src/platforms/ios/runner-lease.ts | 35 +++++- src/platforms/ios/runner-provider.ts | 2 + src/platforms/ios/runner-session-types.ts | 2 + src/platforms/ios/runner-session.ts | 40 ++++++- src/utils/__tests__/args.test.ts | 44 ++++---- src/utils/cli-help.ts | 44 +++++--- website/docs/docs/remote-proxy.md | 37 ++---- 18 files changed, 297 insertions(+), 72 deletions(-) create mode 100644 src/core/runner-lease-context.ts diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 1077543e9..7c829e28a 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -6,6 +6,7 @@ import type { ClickButton } from './click-button.ts'; import type { ElementSelectorKey } from './interactor-types.ts'; import type { SwipePattern } from './scroll-gesture.ts'; import type { SessionSurface } from './session-surface.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; export type MaestroRuntimeFlags = { allowNonHittableCoordinateFallback?: boolean; @@ -43,6 +44,7 @@ export type DispatchContext = ScreenshotDispatchFlags & { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; snapshotInteractiveOnly?: boolean; snapshotDepth?: number; snapshotScope?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 154b6ce4a..8072baa62 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -54,6 +54,7 @@ export async function dispatchCommand( iosXctestrunFile: context?.iosXctestrunFile, iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, iosXctestEnvDir: context?.iosXctestEnvDir, + runnerLeaseContext: context?.runnerLeaseContext, }; const interactor = await getInteractor(device, runnerCtx); emitDiagnostic({ diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index f357be18f..d26e0cdfa 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -4,6 +4,7 @@ import type { ScrollDirection, TransformGestureParams } from './scroll-gesture.t import type { SettingOptions } from '../platforms/permission-utils.ts'; import type { SessionSurface } from './session-surface.ts'; import type { BackendSnapshotResult } from '../backend.ts'; +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; import type { RawSnapshotNode, SnapshotBackend, @@ -19,6 +20,7 @@ export type RunnerContext = { iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; /** Subset of {@link RunnerContext} forwarded to runner command invocations. */ @@ -31,6 +33,7 @@ export type RunnerCallOptions = Pick< | 'iosXctestrunFile' | 'iosXctestDerivedDataPath' | 'iosXctestEnvDir' + | 'runnerLeaseContext' >; export type { BackMode }; diff --git a/src/core/runner-lease-context.ts b/src/core/runner-lease-context.ts new file mode 100644 index 000000000..d6dd5c411 --- /dev/null +++ b/src/core/runner-lease-context.ts @@ -0,0 +1,8 @@ +export type RunnerLogicalLeaseContext = { + leaseId?: string; + clientId?: string; + tenantId?: string; + runId?: string; + leaseProvider?: string; + deviceKey?: string; +}; diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index 3c7431757..e1a8786b7 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -33,6 +33,7 @@ import { } from './daemon/transport.ts'; import { prewarmPngWorker, terminatePngWorker } from './utils/png-worker-client.ts'; import { sleep } from './utils/timeouts.ts'; +import { setRunnerLeaseOwnerStateDir } from './platforms/ios/runner-lease.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -66,6 +67,7 @@ export async function startDaemonRuntime( const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR); const { baseDir, infoPath, lockPath, logPath, sessionsDir } = daemonPaths; const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); + setRunnerLeaseOwnerStateDir(baseDir); cleanupStaleAppLogProcesses(sessionsDir); @@ -182,6 +184,7 @@ export async function startDaemonRuntime( }; if (!acquireDaemonLock(baseDir, lockPath, lockData)) { stderr.write('Daemon lock is held by another process; exiting.\n'); + setRunnerLeaseOwnerStateDir(undefined); exit(0); return null; } @@ -201,6 +204,7 @@ export async function startDaemonRuntime( closeServersBestEffort(servers); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(1); return null; } @@ -228,6 +232,7 @@ export async function startDaemonRuntime( ]); removeInfo(infoPath); releaseDaemonLock(lockPath); + setRunnerLeaseOwnerStateDir(undefined); exit(shutdownOptions.exitCode ?? 0); }; diff --git a/src/daemon/context.ts b/src/daemon/context.ts index ffd99cfd3..e7c8869b0 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -5,6 +5,8 @@ import { type ScreenshotRuntimeFlags, } from '../contracts/screenshot.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import { resolveRunnerLogicalLeaseContext } from './lease-context.ts'; +import type { DaemonRequest } from './types.ts'; export type DaemonCommandContext = DispatchContext & ScreenshotRuntimeFlags; @@ -17,11 +19,13 @@ export function contextFromFlags( appBundleId?: string, traceLogPath?: string, requestId?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const effectiveRequestId = requestId ?? getDiagnosticsMeta().requestId; return { requestId: effectiveRequestId, appBundleId, + runnerLeaseContext: resolveRunnerLogicalLeaseContext({ meta }), activity: flags?.activity, launchConsole: flags?.launchConsole, launchArgs: flags?.launchArgs, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 2fd6e29e0..db3b77485 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -188,6 +188,14 @@ async function completeOpenCommand(params: { logPath, traceLogPath, requestId: req.meta?.requestId, + runnerLeaseContext: contextFromFlags( + logPath, + req.flags, + sessionAppBundleId, + traceLogPath, + req.meta?.requestId, + req.meta, + ).runnerLeaseContext, iosXctestrunFile: req.flags?.iosXctestrunFile, iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, iosXctestEnvDir: req.flags?.iosXctestEnvDir, diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 763e309c4..86c52e83a 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -1,4 +1,5 @@ import type { DaemonRequest } from './types.ts'; +import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts'; import type { LeaseBackend } from '../contracts.ts'; export type LeaseScope = { @@ -18,3 +19,23 @@ export function resolveLeaseScope(req: Pick): L leaseBackend: req.meta?.leaseBackend, }; } + +export function resolveRunnerLogicalLeaseContext( + req: Pick, +): RunnerLogicalLeaseContext | undefined { + const meta = req.meta as (DaemonRequest['meta'] & Record) | undefined; + const context = { + leaseId: readNonEmptyString(meta?.leaseId), + clientId: readNonEmptyString(meta?.clientId), + tenantId: readNonEmptyString(meta?.tenantId), + runId: readNonEmptyString(meta?.runId), + leaseProvider: + readNonEmptyString(meta?.leaseProvider) ?? readNonEmptyString(meta?.leaseBackend), + deviceKey: readNonEmptyString(meta?.deviceKey), + }; + return Object.values(context).some((value) => value !== undefined) ? context : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 115edb418..bc159c0bb 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -207,7 +207,8 @@ export function prepareLockedRequestScope(params: { flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, - ): DaemonCommandContext => contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath); + ): DaemonCommandContext => + contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath, lockedReq.meta); return { type: 'scope', @@ -233,10 +234,11 @@ function contextFromRequestFlags( flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string, + meta?: DaemonRequest['meta'], ): DaemonCommandContext { const requestId = getDiagnosticsMeta().requestId; return { - ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId), + ...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId, meta), requestId, }; } diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 86524d179..8b44320a8 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -110,6 +110,7 @@ import { cleanupRunnerLeasesForOwner, RUNNER_OWNER_START_TIME, RUNNER_OWNER_TOKEN, + setRunnerLeaseOwnerStateDir, writeRunnerLease, type RunnerLease, } from '../runner-lease.ts'; @@ -117,6 +118,7 @@ import { beforeEach(async () => { await abortAllIosRunnerSessions(); vi.resetAllMocks(); + setRunnerLeaseOwnerStateDir(undefined); process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-runner-lease-test-'), ); @@ -609,6 +611,29 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv }); }); +test('runner session startup diagnostics include logical lease context', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-lease-context-sim' }; + + const diagnostics = await captureDiagnostics(async () => { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + }); + + assert.match(diagnostics, /ios_runner_session_startup/); + assert.match(diagnostics, /"logicalLeaseContext"/); + assert.match(diagnostics, /"tenantId":"tenant-123"/); + assert.match(diagnostics, /"runId":"run-456"/); + assert.match(diagnostics, /"leaseId":"lease-789"/); + assert.match(diagnostics, /"leaseProvider":"ios-simulator"/); + assert.match(diagnostics, /"deviceKey":"runner-session-lease-context-sim"/); +}); + test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => { const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' }; mockDevToolsSecurityDisabled(); @@ -693,6 +718,10 @@ test('runner session startup rejects live foreign runner lease', async () => { String((thrown as { details?: Record }).details?.hint), /Do not run prepare ios-runner/, ); + assert.match( + String((thrown as { details?: Record }).details?.hint), + /PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); assert.equal(mockRunCmdBackground.mock.calls.length, 0); assert.equal( mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), @@ -704,6 +733,51 @@ test('runner session startup rejects live foreign runner lease', async () => { } }); +test('runner session busy error includes logical lease context after admission', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-logical-busy-lease-sim' }; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-logical-live', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', + }), + ); + + let thrown: unknown; + await assert.rejects(async () => { + try { + await ensureRunnerSession(device, { + runnerLeaseContext: { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + }, + }); + } catch (error) { + thrown = error; + throw error; + } + }, /busy after device lease admission/); + + assert.ok(thrown instanceof AppError); + assert.deepEqual(thrown.details?.logicalLeaseContext, { + tenantId: 'tenant-123', + runId: 'run-456', + leaseId: 'lease-789', + leaseProvider: 'ios-simulator', + deviceKey: device.id, + }); + assert.match(String(thrown.details?.hint), /five-minute inactivity lease expires/); + assert.match( + String(thrown.details?.hint), + /Runner owner: PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/, + ); + assert.equal(mockRunCmdBackground.mock.calls.length, 0); +}); + test('runner session startup reclaims live foreign runner lease from same state dir', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' }; const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; @@ -738,6 +812,38 @@ test('runner session startup reclaims live foreign runner lease from same state } }); +test('runner session startup reclaims same-state live lease from daemon runtime owner state dir', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-runtime-state-lease-sim' }; + const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; + const stateDir = '/tmp/agent-device-runtime-state'; + delete process.env.AGENT_DEVICE_STATE_DIR; + setRunnerLeaseOwnerStateDir(stateDir); + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-runtime-state', + ownerPid: process.pid, + ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: stateDir, + runnerPid: 4_321, + }), + ); + + try { + const session = await ensureRunnerSession(device, {}); + + assert.equal(session.deviceId, device.id); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-runtime-state/); + } finally { + setRunnerLeaseOwnerStateDir(undefined); + if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; + else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir; + } +}); + test('runner session startup reclaims dead foreign runner lease before launching', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-dead-lease-sim' }; mockIsProcessAlive.mockImplementation((pid) => pid !== 999_999_999 && pid !== 999_999_998); diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index cf6045a2f..1cd707bea 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -72,6 +72,7 @@ export function iosRunnerOverrides( iosXctestrunFile: ctx.iosXctestrunFile, iosXctestDerivedDataPath: ctx.iosXctestDerivedDataPath, iosXctestEnvDir: ctx.iosXctestEnvDir, + runnerLeaseContext: ctx.runnerLeaseContext, }; return { runnerOpts, diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index 506fcaac8..9d37dbd60 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -6,6 +6,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { AppError } from '../../utils/errors.ts'; import { acquireProcessLock } from '../../utils/process-lock.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; const RUNNER_LEASE_SCHEMA_VERSION = 1; const RUNNER_LEASE_LOCK_TIMEOUT_MS = 30_000; @@ -16,6 +17,8 @@ const RUNNER_OWNER_PID = process.pid; export const RUNNER_OWNER_START_TIME = readProcessStartTime(process.pid); export const RUNNER_OWNER_TOKEN = buildRunnerOwnerToken(RUNNER_OWNER_PID, RUNNER_OWNER_START_TIME); +let runnerLeaseOwnerStateDir: string | undefined; + export type RunnerLease = { schemaVersion: 1; deviceId: string; @@ -72,6 +75,10 @@ export function buildRunnerLease(params: { }; } +export function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { + runnerLeaseOwnerStateDir = stateDir?.trim() || undefined; +} + export async function withRunnerLeaseLock(deviceId: string, task: () => Promise): Promise { const release = await acquireProcessLock({ lockDirPath: `${resolveRunnerLeasePath(deviceId)}.lock`, @@ -110,6 +117,7 @@ function classifyRunnerLease(lease: RunnerLease | null): RunnerLeaseState { export async function prepareRunnerLeaseForStartup( deviceId: string, cleanup: RunnerLeaseCleanupAdapter, + logicalLeaseContext?: RunnerLogicalLeaseContext, ): Promise { const state = classifyRunnerLease(readRunnerLease(deviceId)); if (state.type === 'empty') { @@ -123,15 +131,18 @@ export async function prepareRunnerLeaseForStartup( } throw new AppError( 'COMMAND_FAILED', - `iOS runner for ${deviceId} is already owned by another agent-device daemon`, + logicalLeaseContext + ? `iOS runner for ${deviceId} is busy after device lease admission` + : `iOS runner for ${deviceId} is already owned by another agent-device daemon`, { deviceId, + logicalLeaseContext, ownerPid: state.lease.ownerPid, ownerStartTime: state.lease.ownerStartTime, ownerStateDir: state.lease.ownerStateDir, ownerToken: state.lease.ownerToken, sessionId: state.lease.sessionId, - hint: buildBusyRunnerLeaseHint(state.lease), + hint: buildBusyRunnerLeaseHint(state.lease, logicalLeaseContext), }, ); } @@ -146,14 +157,30 @@ function isSameStateDirRunnerLease(lease: RunnerLease): boolean { } function readCurrentStateDir(): string | undefined { + if (runnerLeaseOwnerStateDir) return runnerLeaseOwnerStateDir; return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } -function buildBusyRunnerLeaseHint(lease: RunnerLease): string { +function buildBusyRunnerLeaseHint( + lease: RunnerLease, + logicalLeaseContext?: RunnerLogicalLeaseContext, +): string { const owner = `PID ${lease.ownerPid}`; const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : ''; + const currentStateDir = readCurrentStateDir(); + const current = + currentStateDir && currentStateDir !== lease.ownerStateDir + ? ` Current daemon state dir is ${currentStateDir}.` + : ''; + if (logicalLeaseContext) { + return [ + `The device is busy because another active device lease owns it, or the runner is owned by another daemon/process after lease admission. Runner owner: ${owner}${stateDir}.${current}`, + 'Retry after the owning session closes or after the five-minute inactivity lease expires.', + 'If this persists after expiry, inspect the runner owner details and clean the stale daemon state on the machine with simulator access.', + ].join(' '); + } return [ - `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`, + `Runner owner details: ${owner}${stateDir}.${current} Retry after the owning runner finishes.`, 'Do not run prepare ios-runner from another daemon/client to recover this; a live foreign runner lease cannot be released by the remote client.', ].join(' '); } diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 277379a68..f14ff8c46 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerCommand } from './runner-contract.ts'; import type { @@ -14,6 +15,7 @@ export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { cleanStaleBundles?: boolean; startupTimeoutMs?: number; requestId?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 3a114b207..f0ceb870f 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -1,3 +1,4 @@ +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; @@ -21,6 +22,7 @@ export type RunnerSession = { lastHealthyMutation?: { atMs: number; appBundleId?: string }; startupTimings?: Record; startupTimingsReported?: boolean; + logicalLeaseContext?: RunnerLogicalLeaseContext; simulatorSetRedirect?: { release: () => Promise }; lease?: RunnerLease; }; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 8b6e3da18..516af68d4 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -3,6 +3,7 @@ import { runCmdBackground, type ExecResult, type ExecBackgroundResult } from '.. import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { Deadline } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; @@ -108,8 +109,20 @@ async function startRunnerSessionWithLease( options: RunnerSessionOptions, ): Promise { const startupTimings: Record = {}; + const logicalLeaseContext = normalizeRunnerLogicalLeaseContext( + options.runnerLeaseContext, + device.id, + ); + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_session_startup', + data: { + deviceId: device.id, + logicalLeaseContext, + }, + }); await measureRunnerStartupStep(startupTimings, 'cleanup_stale_xcodebuild', async () => { - await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter); + await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter, logicalLeaseContext); }); await measureRunnerStartupStep(startupTimings, 'ensure_booted', async () => { await ensureBootedIfNeeded(device); @@ -222,6 +235,7 @@ async function startRunnerSessionWithLease( ready: false, startupTimeoutMs: normalizeRunnerStartupTimeoutMs(options.startupTimeoutMs), startupTimings, + logicalLeaseContext, simulatorSetRedirect: simulatorSetRedirect ?? undefined, lease, }; @@ -262,6 +276,7 @@ async function resolveReusableRunnerSession( sessionId: existing.sessionId, ready: existing.ready, cache: existingArtifact.cache, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -295,6 +310,7 @@ async function resolveReusableRunnerSession( deviceId: device.id, sessionId: existing.sessionId, ready: existing.ready, + logicalLeaseContext: existing.logicalLeaseContext, }, }); return existing; @@ -889,7 +905,29 @@ function emitRunnerStartupTimings(session: RunnerSession, command: string): void command, sessionId: session.sessionId, ready: session.ready, + logicalLeaseContext: session.logicalLeaseContext, timings: session.startupTimings, }, }); } + +function normalizeRunnerLogicalLeaseContext( + context: RunnerLogicalLeaseContext | undefined, + deviceKey: string, +): RunnerLogicalLeaseContext | undefined { + if (!context) return undefined; + const normalized = { + leaseId: readOptionalContextString(context.leaseId), + clientId: readOptionalContextString(context.clientId), + tenantId: readOptionalContextString(context.tenantId), + runId: readOptionalContextString(context.runId), + leaseProvider: readOptionalContextString(context.leaseProvider), + deviceKey: readOptionalContextString(context.deviceKey) ?? deviceKey, + }; + const entries = Object.entries(normalized).filter(([, value]) => value !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function readOptionalContextString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 95349d3ce..75e950144 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1232,12 +1232,14 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /verify the action with diff snapshot -i or snapshot --diff/); assert.match(usageText, /Sparse or AX-unavailable snapshot/); assert.match(usageText, /macOS context menus use click --button secondary/); - assert.match(usageText, /Direct proxy: Cloud\/Linux clients can use iOS simulators/); - assert.match(usageText, /A proxy URL\/token means direct proxy mode/); - assert.match(usageText, /Direct proxy sessions: choose one explicit --session/); - assert.match(usageText, /do not use connect, --remote-config, tenant, run, or lease flags/); - assert.match(usageText, /Cloud\/remote-config profiles are separate from direct proxy/); - assert.match(usageText, /Do not substitute --config/); + assert.match( + usageText, + /Remote lifecycle: use connect, then open, commands, close, and disconnect/, + ); + assert.match(usageText, /connect proxy --daemon-base-url /); + assert.match(usageText, /proxy device lease is automatic on open/); + assert.match(usageText, /expires after five minutes of inactivity/); + assert.match(usageText, /disconnect releases local connection state/); assert.match(usageText, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( @@ -1575,11 +1577,10 @@ test('usageForCommand resolves remote help topic', () => { const help = usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); assert.match(help, /agent-device connect/); - assert.match(help, /There are two different remote modes/); - assert.match(help, /Direct proxy: agent-device proxy exposes a Mac you control/); - assert.match(help, /A cloud\/Linux client can use iOS simulators through that proxied Mac/); - assert.match(help, /Use one explicit --session across open, snapshot, interactions, and close/); - assert.match(help, /Do not use connect, --remote-config, tenant, run, or lease flags/); + assert.match(help, /Remote connection providers use the same lifecycle/); + assert.match(help, /connect -> open -> commands -> close -> disconnect/); + assert.match(help, /Direct proxy: agent-device connect proxy/); + assert.match(help, /stores the shared proxy profile and client identity/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /Script flow, per-command config/); @@ -1587,17 +1588,18 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device proxy --port 4310/); assert.match( help, - /--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, - ); - assert.match(help, /agent-device open Maps --session maps/); - assert.match(help, /agent-device snapshot -i --session maps/); - assert.match(help, /agent-device close --session maps/); - assert.match(help, /store daemonBaseUrl and daemonAuthToken in normal agent-device\.json/); - assert.match(help, /keep the same explicit --session until close/); - assert.match(help, /do not run prepare ios-runner from the remote client/); - assert.match(help, /same-proxy-state stale runner leases are reclaimed/); + /connect proxy --daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, + ); + assert.match(help, /agent-device open Maps --platform ios/); + assert.match(help, /agent-device snapshot -i --platform ios/); + assert.match(help, /agent-device close/); + assert.match(help, /lease is acquired lazily on open/); + assert.match(help, /expires after five minutes without commands/); + assert.match(help, /Multiple agents can share one proxy/); + assert.match(help, /disconnect releases the connection lease and local state/); + assert.match(help, /A busy direct-proxy device error means another agent owns the device/); + assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); - assert.match(help, /do not use agent-device auth, connect, disconnect, --remote-config/); assert.match(help, /Do not use --config as a remote profile flag/); assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 3898d0e7a..6629dca7c 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -80,9 +80,9 @@ const AGENT_QUICKSTART_LINES = [ 'Raw coordinates are fallback-only: use snapshot -i --json rects when iOS refs no-op or child refs are missing, then verify the action with diff snapshot -i or snapshot --diff.', 'Sparse or AX-unavailable snapshot: use screenshot for visual truth, press the visible coordinate to leave the bad screen, then retry AX with snapshot -i.', 'macOS context menus use click --button secondary, then snapshot -i. Longpress is for mobile hold gestures, not macOS secondary-click menus.', - 'Direct proxy: Cloud/Linux clients can use iOS simulators through a Mac running agent-device proxy. A proxy URL/token means direct proxy mode: use --daemon-base-url plus --daemon-auth-token, or saved daemonBaseUrl/daemonAuthToken config.', - 'Direct proxy sessions: choose one explicit --session and reuse it for open/snapshot/interactions/close; do not use connect, --remote-config, tenant, run, or lease flags.', - 'Cloud/remote-config profiles are separate from direct proxy: use connect or --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', + 'Remote lifecycle: use connect, then open, commands, close, and disconnect. Cloud, remote-config, direct proxy, and limrun are connection providers under the same flow.', + 'Direct proxy: run agent-device connect proxy --daemon-base-url before using a shared Mac proxy. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state.', + 'Busy direct-proxy device: another agent owns the local/proxy iOS device until it closes or the five-minute inactivity lease expires.', 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', @@ -268,8 +268,10 @@ Validation and evidence: Android animations: settings animations off/on, not animations disable/restore. Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. Network headers: network dump --include headers; do not write network log headers. - Direct proxy to a Mac you control: cloud/Linux clients can still use iOS simulators through the proxied Mac. Use the printed /agent-device daemon base URL and auth token, or store them as daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close. Do not use connect, --remote-config, tenant, run, or lease flags for direct proxy simulators. - Cloud/remote-config profiles: use connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. + Remote lifecycle: cloud, remote-config, direct proxy, and limrun are connection providers under the same flow: connect, open, commands, close, disconnect. + Remote config profile: agent-device connect --remote-config ./remote-config.json; then run normal commands and disconnect. + Direct proxy to a Mac you control: cloud/Linux clients can use local/proxy iOS devices through the proxied Mac. Run agent-device connect proxy --daemon-base-url first; connect stores the profile and client identity. The proxy device lease is automatic on open, refreshes on commands, expires after five minutes of inactivity, and disconnect releases local connection state. close releases the session/device lease where supported. + Busy direct-proxy device: another agent owns the device until it closes or the five-minute inactivity lease expires. Use lease expiry or close for normal contention. Web: agent-device uses a managed, pinned agent-browser backend as an implementation detail. Use --platform web when a browser step belongs inside an agent-device session, replay, batch, MCP, or typed-client flow; use agent-browser directly for standalone web automation. Run agent-device web setup before first use, then agent-device web doctor for backend health checks. Web automation requires Node 24+. agent-device web setup agent-device web doctor @@ -646,19 +648,26 @@ Android physical-device prerequisites: summary: 'Direct proxy, cloud profiles, and remote config', body: `agent-device help remote -There are two different remote modes: - 1. Direct proxy: agent-device proxy exposes a Mac you control. A cloud/Linux client can use iOS simulators through that proxied Mac. Use --daemon-base-url plus --daemon-auth-token, or store daemonBaseUrl and daemonAuthToken in agent-device.json. Use one explicit --session across open, snapshot, interactions, and close so implicit cwd-scoped default sessions do not diverge. Do not use connect, --remote-config, tenant, run, or lease flags for this mode. - 2. Cloud/profile: the cloud connection profile or a local --remote-config owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. +Remote connection providers use the same lifecycle: + connect -> open -> commands -> close -> disconnect + +Providers: + Cloud: agent-device connect discovers the cloud profile. + Remote config: agent-device connect --remote-config ./remote-config.json uses a local profile. + Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. + Limrun: agent-device connect limrun uses the generated limrun profile when available. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 On the remote client: - agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device open Maps --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device snapshot -i --session maps --platform ios --device "iPhone 17 Pro" --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token - agent-device close --session maps --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device devices --platform ios + agent-device open Maps --platform ios --device "iPhone 17 Pro" + agent-device snapshot -i --platform ios --device "iPhone 17 Pro" + agent-device close + agent-device disconnect Cloud profile flow: agent-device connect @@ -680,11 +689,14 @@ Script flow, per-command config: Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. Use connect without --remote-config when the cloud control plane owns the connection profile. - Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. - Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token; do not use agent-device auth, connect, disconnect, --remote-config, tenant, run, or lease flags for this direct proxy flow. - For repeated direct proxy commands, store daemonBaseUrl and daemonAuthToken in normal agent-device.json CLI config. Keep platform selection on each command or workflow, and keep the same explicit --session until close. + Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. + Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token, then run agent-device connect proxy --daemon-base-url before normal commands. + connect proxy establishes the connection profile and client identity. The proxy device lease is acquired lazily on open, refreshes on command activity, and expires after five minutes without commands. + Multiple agents can share one proxy when each uses the normal connect proxy/open/command/disconnect flow; the daemon isolates sessions by client. + disconnect releases the connection lease and local state. close releases the session/device lease where supported. + A busy direct-proxy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. Keep the proxy token secret. Anyone with the token can control the proxied daemon. - If iOS snapshot/interaction reports that the runner is already owned by another agent-device daemon, do not run prepare ios-runner from the remote client. Retry the original snapshot or interaction; same-proxy-state stale runner leases are reclaimed by the proxy daemon. If the conflict repeats, the Mac operator should close the owning session or clean the conflicting local daemon. + If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, do not run prepare ios-runner from the remote client. Retry after the owning session closes or after the five-minute inactivity lease expires; if the conflict repeats after expiry, inspect the runner owner details and clean stale daemon state on the machine with simulator access. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a2f7de99b..f4aa8e580 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -31,41 +31,22 @@ By default the proxy binds `127.0.0.1`. Use `--host 0.0.0.0` only when you inten ## Remote Client -On the machine running the agent, use the public tunnel origin with the `/agent-device` base path: +On the machine running the agent, connect to the public tunnel origin with the `/agent-device` base path: ```bash -export AGENT_DEVICE_DAEMON_BASE_URL="https://example.trycloudflare.com/agent-device" -export AGENT_DEVICE_DAEMON_AUTH_TOKEN="" - +agent-device connect proxy \ + --daemon-base-url https://example.trycloudflare.com/agent-device \ + --daemon-auth-token agent-device devices --platform ios agent-device open MyApp --platform ios agent-device snapshot --platform ios +agent-device close +agent-device disconnect ``` -You can also pass the values per command: - -```bash -agent-device devices \ - --daemon-base-url https://example.trycloudflare.com/agent-device \ - --daemon-auth-token -``` - -For repeated use, put the remote client settings in normal CLI config: - -```json -{ - "daemonBaseUrl": "https://example.trycloudflare.com/agent-device", - "daemonAuthToken": "" -} -``` +`connect proxy` stores the proxy profile and client identity. The proxy device lease is acquired automatically on `open`, refreshes on command activity, and expires after five minutes without commands. `disconnect` releases the connection lease and local state; `close` releases the session/device lease where supported. -With `agent-device.json` in the working directory, normal commands pick up those defaults: - -```bash -agent-device devices -agent-device open MyApp -agent-device snapshot -``` +Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, command, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or the five-minute inactivity lease expires. Do not commit a config file that contains a live `daemonAuthToken`. @@ -81,4 +62,4 @@ Remote clients read `/health` before issuing commands and compare the daemon RPC ## Cleanup -Stop the tunnel and the `agent-device proxy` process when the remote session is done. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly. +Run `agent-device disconnect` when the remote session is done. Stop the tunnel and the `agent-device proxy` process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly; use lease expiry or `close` for normal device contention.