From f516919ebd6b2fe52d28c648734918e21b960d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 21:05:31 +0200 Subject: [PATCH 1/2] fix: clarify proxy runner ownership --- src/commands/management/prepare.ts | 2 +- src/daemon-client-lifecycle.ts | 23 +++++- .../ios/__tests__/runner-session.test.ts | 71 ++++++++++++++++--- src/platforms/ios/runner-lease.ts | 30 +++++++- src/utils/__tests__/args.test.ts | 34 +++++++-- .../__tests__/daemon-client-lifecycle.test.ts | 41 +++++++++++ src/utils/cli-help.ts | 40 ++++++----- .../suites/agent-device-smoke-suite.ts | 53 ++++++++++++++ 8 files changed, 259 insertions(+), 35 deletions(-) diff --git a/src/commands/management/prepare.ts b/src/commands/management/prepare.ts index 2053badcf..2e100890a 100644 --- a/src/commands/management/prepare.ts +++ b/src/commands/management/prepare.ts @@ -32,7 +32,7 @@ const prepareCliSchema = { usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', listUsageOverride: 'prepare', helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', summary: 'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation', positionalArgs: ['ios-runner'], diff --git a/src/daemon-client-lifecycle.ts b/src/daemon-client-lifecycle.ts index 893867dc6..33a1dac22 100644 --- a/src/daemon-client-lifecycle.ts +++ b/src/daemon-client-lifecycle.ts @@ -166,7 +166,7 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise< const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) return null; - const existingReachable = await canConnect(existing, settings.transportPreference); + const existingReachable = await canConnectReusableDaemon(existing, settings.transportPreference); if (isReusableDaemonInfo(existing, existingReachable)) return existing; emitDaemonTakeoverNotice(existing, existingReachable, settings.paths.baseDir); @@ -175,6 +175,27 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise< return null; } +async function canConnectReusableDaemon( + info: DaemonInfo, + preference: DaemonTransportPreference, +): Promise { + try { + return await canConnect(info, preference); + } catch (error) { + if (isDaemonTransportUnavailableError(error)) return false; + throw error; + } +} + +function isDaemonTransportUnavailableError(error: unknown): boolean { + return ( + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + (error.message === 'Daemon HTTP endpoint is unavailable' || + error.message === 'Daemon socket endpoint is unavailable') + ); +} + function isReusableDaemonInfo(info: DaemonInfo, reachable: boolean): boolean { return ( info.version === readVersion() && diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index eb8a34d68..86524d179 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -662,25 +662,80 @@ test('runner session startup kills legacy ownerless xcodebuild before launching test('runner session startup rejects live foreign runner lease', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-busy-lease-sim' }; + const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR; + process.env.AGENT_DEVICE_STATE_DIR = '/tmp/agent-device-current'; writeRunnerLease( makeRunnerLease({ deviceId: device.id, ownerToken: 'owner-foreign-live', ownerPid: process.pid, ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: '/tmp/agent-device-owner', }), ); - await assert.rejects( - () => ensureRunnerSession(device, {}), - /already owned by another agent-device daemon/, - ); + try { + let thrown: unknown; + await assert.rejects(async () => { + try { + await ensureRunnerSession(device, {}); + } catch (error) { + thrown = error; + throw error; + } + }, /already owned by another agent-device daemon/); + + assert.equal( + (thrown as { details?: Record }).details?.ownerStateDir, + '/tmp/agent-device-owner', + ); + assert.match( + String((thrown as { details?: Record }).details?.hint), + /Do not run prepare ios-runner/, + ); + assert.equal(mockRunCmdBackground.mock.calls.length, 0); + assert.equal( + mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), + false, + ); + } finally { + if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR; + else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir; + } +}); - assert.equal(mockRunCmdBackground.mock.calls.length, 0); - assert.equal( - mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'), - false, +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; + const stateDir = '/tmp/agent-device-proxy-state'; + process.env.AGENT_DEVICE_STATE_DIR = stateDir; + writeRunnerLease( + makeRunnerLease({ + deviceId: device.id, + ownerToken: 'owner-foreign-same-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); + assert.deepEqual(mockCleanupTempFile.mock.calls, [ + [`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.xctestrun`], + [`/tmp/AgentDeviceRunner.env.session-${device.id}-owner-foreign-same-state-8123.json`], + ]); + const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall); + assert.ok(pkillCalls.length >= 2); + assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-same-state/); + } finally { + 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 () => { diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index a94d77e36..e5838a0a9 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -22,6 +22,7 @@ export type RunnerLease = { ownerToken: string; ownerPid: number; ownerStartTime: string | null; + ownerStateDir?: string; sessionId: string; runnerPid: number | null; port: number; @@ -61,6 +62,7 @@ export function buildRunnerLease(params: { ownerToken: RUNNER_OWNER_TOKEN, ownerPid: RUNNER_OWNER_PID, ownerStartTime: RUNNER_OWNER_START_TIME, + ownerStateDir: process.env.AGENT_DEVICE_STATE_DIR, sessionId: params.sessionId, runnerPid: params.runnerPid ?? null, port: params.port, @@ -115,6 +117,10 @@ export async function prepareRunnerLeaseForStartup( return; } if (state.type === 'busy') { + if (isSameStateDirRunnerLease(state.lease)) { + await cleanupLeasedRunnerProcesses(state.lease, 'same-state-dir', cleanup); + return; + } throw new AppError( 'COMMAND_FAILED', `iOS runner for ${deviceId} is already owned by another agent-device daemon`, @@ -122,15 +128,31 @@ export async function prepareRunnerLeaseForStartup( deviceId, ownerPid: state.lease.ownerPid, ownerStartTime: state.lease.ownerStartTime, + ownerStateDir: state.lease.ownerStateDir, ownerToken: state.lease.ownerToken, sessionId: state.lease.sessionId, - hint: 'Use a different simulator/session, wait for the other run to finish, or stop the owning daemon before retrying.', + hint: buildBusyRunnerLeaseHint(state.lease), }, ); } await cleanupLeasedRunnerProcesses(state.lease, state.type, cleanup); } +function isSameStateDirRunnerLease(lease: RunnerLease): boolean { + const currentStateDir = process.env.AGENT_DEVICE_STATE_DIR?.trim(); + if (!currentStateDir || !lease.ownerStateDir) return false; + return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir); +} + +function buildBusyRunnerLeaseHint(lease: RunnerLease): string { + const owner = `PID ${lease.ownerPid}`; + const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : ''; + return [ + `The Mac operator must stop the owning daemon (${owner}${stateDir}) or wait for that run to finish, then retry.`, + '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(' '); +} + export async function cleanupOwnedRunnerLease( deviceId: string, cleanup: RunnerLeaseCleanupAdapter, @@ -240,6 +262,7 @@ function normalizeRunnerLease(value: unknown, deviceId: string): RunnerLease | n deviceId, ...fields, ownerStartTime: readOptionalString(raw.ownerStartTime), + ownerStateDir: readOptionalString(raw.ownerStateDir) ?? undefined, runnerPid: readPositiveInteger(raw.runnerPid), }; } @@ -286,15 +309,16 @@ function isRunnerLeaseOwnerAlive(lease: RunnerLease): boolean { async function cleanupLeasedRunnerProcesses( lease: RunnerLease, - reason: 'owned' | 'stale', + reason: 'owned' | 'stale' | 'same-state-dir', cleanup: RunnerLeaseCleanupAdapter, ): Promise { emitDiagnostic({ - level: reason === 'stale' ? 'warn' : 'debug', + level: reason === 'stale' || reason === 'same-state-dir' ? 'warn' : 'debug', phase: 'ios_runner_lease_cleanup', data: { deviceId: lease.deviceId, ownerPid: lease.ownerPid, + ownerStateDir: lease.ownerStateDir, ownerToken: lease.ownerToken, runnerPid: lease.runnerPid, sessionId: lease.sessionId, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 125194053..fab35f201 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1096,6 +1096,10 @@ 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.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.match(usageText, /Agent Quickstart:/); assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/); assert.match(usageText, /Use selectors or refs as positional targets/); @@ -1122,7 +1126,11 @@ 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, /Remote workflow profiles use --remote-config/); + assert.match(usageText, /Cloud\/Linux clients can use iOS simulators/); + assert.match(usageText, /A proxy URL\/token means direct proxy mode/); + assert.match(usageText, /choose one explicit --session/); + assert.match(usageText, /do not use connect or --remote-config/); + assert.match(usageText, /Cloud\/remote-config profiles are a separate mode/); assert.match(usageText, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( @@ -1230,6 +1238,10 @@ test('usageForCommand documents prepare ios-runner', () => { assert.match(help, /XCTest runner/); assert.match(help, /separate daemon/); assert.match(help, /clean:daemon after prepare/); + assert.match( + help, + /not a recovery step for "runner already owned by another agent-device daemon"/, + ); assert.match(help, /Runner build\/start output is written to the session runner\.log/); }); @@ -1321,6 +1333,10 @@ test('usageForCommand resolves workflow help topic', () => { assert.match(help, /metro prepare --kind expo/); assert.match(help, /agent-device prepare ios-runner --platform ios --timeout 240000/); assert.match(help, /prepare ios-runner builds\/reuses the XCTest runner/); + assert.match( + help, + /not a recovery step for "runner already owned by another agent-device daemon"/, + ); assert.match(help, /prepared runner does not keep a live lease/); assert.match(help, /help react-devtools/); assert.match(help, /help react-native/); @@ -1443,7 +1459,11 @@ 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, /without --remote-config/); + 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, /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/); @@ -1453,11 +1473,15 @@ test('usageForCommand resolves remote help topic', () => { 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 platform selection on each command or workflow/); + 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/); assert.match(help, /same --remote-config to every operational command/); - assert.match(help, /do not use agent-device auth for this direct proxy flow/); - assert.match(help, /Do not use --remote-config unless you are using the tenant\/run\/lease/); + 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/__tests__/daemon-client-lifecycle.test.ts b/src/utils/__tests__/daemon-client-lifecycle.test.ts index f1bf25085..96ac1ba76 100644 --- a/src/utils/__tests__/daemon-client-lifecycle.test.ts +++ b/src/utils/__tests__/daemon-client-lifecycle.test.ts @@ -549,6 +549,47 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem } }); +test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is requested', async (t) => { + if (!(await supportsLoopbackBind())) { + t.skip('loopback listeners are not permitted in this environment'); + return; + } + + const stateDir = makeTempStateDir('agent-device-daemon-http-takeover-'); + const paths = resolveDaemonPaths(stateDir); + const freshDaemon = await startHttpDaemonFixture({ via: 'fresh-http-daemon' }); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + installSpawnedHttpDaemon(paths, freshDaemon.port); + writeDaemonInfo(paths, { + port: 65_532, + transport: 'socket', + pid: 999_999, + }); + const stderrCapture = captureStderr(); + + try { + const response = await sendToDaemon({ + session: 'default', + command: 'http-takeover-smoke', + positionals: [], + flags: { stateDir, daemonTransport: 'http' }, + meta: { requestId: 'req-http-takeover' }, + }); + + assert.deepEqual(response, { ok: true, data: { via: 'fresh-http-daemon' } }); + assert.equal(mockRunCmdDetached.mock.calls.length, 1); + assert.deepEqual(freshDaemon.seenPaths, ['GET /health', 'POST /rpc']); + assert.equal( + stderrCapture.read(), + `Replacing daemon (pid 999999, v${readVersion()}) in ${paths.baseDir}: unreachable\n`, + ); + } finally { + stderrCapture.restore(); + await closeLoopbackServer(freshDaemon.server); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + function captureStderr(): { read: () => string; restore: () => void } { const originalWrite = process.stderr.write.bind(process.stderr); let captured = ''; diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index b2aef4df9..5690b408a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -56,7 +56,7 @@ 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.', - 'Remote workflow profiles use --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', + 'Cloud/Linux clients can use iOS simulators when they are connected to a Mac through agent-device proxy. A proxy URL/token means direct proxy mode: use --daemon-base-url plus --daemon-auth-token, or saved daemonBaseUrl/daemonAuthToken config; do not use connect or --remote-config. For direct proxy, choose one explicit --session and reuse it for open/snapshot/interactions/close so cwd-scoped default sessions do not diverge. Cloud/remote-config profiles are a separate mode that uses connect or --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', '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.', @@ -130,7 +130,7 @@ Bootstrap: agent-device prepare ios-runner --platform ios --timeout 240000 If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. - In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. If the replay/test step starts a separate daemon, run clean:daemon after prepare so the prepared runner does not keep a live lease owned by the prepare daemon. + In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, run clean:daemon after prepare so the prepared runner does not keep a live lease owned by the prepare daemon. CI may cache ~/.agent-device/ios-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues. Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. @@ -242,7 +242,8 @@ 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. - Remote/cloud: connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. + 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. 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 @@ -559,10 +560,22 @@ Android physical-device prerequisites: Android does not need the iOS runner signing setup. For React Native/Expo Metro reachability, read help react-native.`, }, remote: { - summary: 'Remote config, tenant, lease, and remote host flow', + summary: 'Direct proxy, cloud profiles, and remote config', body: `agent-device help remote -Use remote config or the cloud connection profile when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. +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. + +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 Cloud profile flow: agent-device connect @@ -581,21 +594,14 @@ Script flow, per-command config: agent-device snapshot --remote-config ./remote-config.json agent-device disconnect --remote-config ./remote-config.json -Direct proxy flow for a remote Mac: - 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 devices - 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 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. Do not use --remote-config unless you are using the tenant/run/lease remote connection flow. + 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. 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. 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. @@ -814,14 +820,14 @@ CLI to control iOS and Android devices for AI agents. const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); return `${header} +${workflowsSection} + ${commandLines} ${flagsSection} ${quickstartSection} -${workflowsSection} - ${configSection} ${environmentSection} diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index e1d722f51..2674b309c 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -2050,6 +2050,39 @@ const SKILL_GUIDANCE_CASES: Case[] = [ /adb shell/i, ], }), + makeCase({ + id: 'direct-proxy-remote-simulator-flow', + contract: [ + 'The agent is running in a cloud Linux environment', + 'The user says a remote iOS simulator is available through agent-device proxy', + 'Proxy daemon base URL: https://example.trycloudflare.com/agent-device', + 'Proxy daemon auth token: proxy-secret', + 'Platform: iOS', + 'Device: iPhone 17 Pro', + 'App: Maps', + 'This is direct proxy mode, not cloud/profile mode', + 'Use one explicit session for the whole flow: maps', + ], + task: 'Plan commands to open Maps on the remote simulator through the direct proxy, capture interactive refs, and close the session.', + outputs: [ + /open\s+Maps\b/i, + /snapshot\b[^\n]*-i\b/i, + /close\b/i, + /--session maps/i, + /--platform ios/i, + /--device ["']iPhone 17 Pro["']/i, + /--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device/i, + /--daemon-auth-token proxy-secret/i, + ], + forbiddenOutputs: [ + plannedCommand('connect'), + plannedCommand('disconnect'), + /--remote-config/i, + /--tenant/i, + /--run-id/i, + /--lease-id/i, + ], + }), makeCase({ id: 'remote-cloud-connect-flow', contract: [ @@ -2110,6 +2143,26 @@ const SKILL_GUIDANCE_CASES: Case[] = [ ], forbiddenOutputs: [/--daemon-base-url/i, /--tenant/i, /--run-id/i], }), + makeCase({ + id: 'remote-ios-runner-lease-retry-snapshot', + contract: [ + 'Direct proxy flow to a remote Mac is already configured', + 'Platform: iOS', + 'Device: iPhone 17 Pro', + 'The remote agent already opened Maps successfully', + 'The first interactive snapshot failed: iOS runner is already owned by another agent-device daemon', + 'The proxy daemon can reclaim stale same-state runner leases on retry', + ], + task: 'Plan the next remote client command. Do not run prepare ios-runner or prescribe host process cleanup; retry the original interactive snapshot.', + outputs: [/snapshot\b[^\n]*-i\b/i, /--platform ios/i, /--device ["']iPhone 17 Pro["']/i], + forbiddenOutputs: [ + plannedCommand('prepare ios-runner'), + /prepare\s+ios-runner/i, + plannedCommand('open'), + /\bkill\b/i, + /clean:daemon/i, + ], + }), makeCase({ id: 'macos-menubar-surface', contract: [ From 946e788d827e764799f251cd05721d185a2a6795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 21:13:53 +0200 Subject: [PATCH 2/2] fix: tighten proxy runner review follow-ups --- src/daemon-client-lifecycle.ts | 11 ++++++++--- src/daemon-client-transport.ts | 10 ++++++---- src/platforms/ios/runner-lease.ts | 9 +++++++-- src/utils/__tests__/args.test.ts | 9 +++++---- src/utils/cli-help.ts | 4 +++- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/daemon-client-lifecycle.ts b/src/daemon-client-lifecycle.ts index 33a1dac22..90358f8d3 100644 --- a/src/daemon-client-lifecycle.ts +++ b/src/daemon-client-lifecycle.ts @@ -32,7 +32,12 @@ import { type DaemonInfo, type DaemonStartupCleanupResult, } from './daemon-client-metadata.ts'; -import { canConnect, readRemoteDaemonHealth } from './daemon-client-transport.ts'; +import { + canConnect, + DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE, + DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE, + readRemoteDaemonHealth, +} from './daemon-client-transport.ts'; export type DaemonClientSettings = { paths: DaemonPaths; @@ -191,8 +196,8 @@ function isDaemonTransportUnavailableError(error: unknown): boolean { return ( error instanceof AppError && error.code === 'COMMAND_FAILED' && - (error.message === 'Daemon HTTP endpoint is unavailable' || - error.message === 'Daemon socket endpoint is unavailable') + (error.message === DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE || + error.message === DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE) ); } diff --git a/src/daemon-client-transport.ts b/src/daemon-client-transport.ts index ba073e37e..cda3a6cd8 100644 --- a/src/daemon-client-transport.ts +++ b/src/daemon-client-transport.ts @@ -22,6 +22,8 @@ type ResolvedDaemonTransport = 'socket' | 'http'; const LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS = 500; const REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000; +export const DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon HTTP endpoint is unavailable'; +export const DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE = 'Daemon socket endpoint is unavailable'; export type RemoteDaemonHealth = { reachable: boolean; @@ -254,8 +256,8 @@ function requireDaemonTransport( throw new AppError( 'COMMAND_FAILED', transport === 'http' - ? 'Daemon HTTP endpoint is unavailable' - : 'Daemon socket endpoint is unavailable', + ? DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE + : DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE, ); } @@ -294,7 +296,7 @@ async function sendSocketRequest( timeoutMs: number | undefined, ): Promise { const port = info.port; - if (!port) throw new AppError('COMMAND_FAILED', 'Daemon socket endpoint is unavailable'); + if (!port) throw new AppError('COMMAND_FAILED', DAEMON_SOCKET_ENDPOINT_UNAVAILABLE_MESSAGE); return new Promise((resolve, reject) => { let requestWritten = false; const socket = net.createConnection({ host: '127.0.0.1', port }, () => { @@ -360,7 +362,7 @@ async function sendHttpRequest( : info.httpPort ? new URL(`http://127.0.0.1:${info.httpPort}/rpc`) : null; - if (!rpcUrl) throw new AppError('COMMAND_FAILED', 'Daemon HTTP endpoint is unavailable'); + if (!rpcUrl) throw new AppError('COMMAND_FAILED', DAEMON_HTTP_ENDPOINT_UNAVAILABLE_MESSAGE); const rpcPayload = JSON.stringify(buildHttpRpcPayload(req, { includeTokenParam: !info.baseUrl })); const headers: Record = { 'content-type': 'application/json', diff --git a/src/platforms/ios/runner-lease.ts b/src/platforms/ios/runner-lease.ts index e5838a0a9..506fcaac8 100644 --- a/src/platforms/ios/runner-lease.ts +++ b/src/platforms/ios/runner-lease.ts @@ -62,7 +62,7 @@ export function buildRunnerLease(params: { ownerToken: RUNNER_OWNER_TOKEN, ownerPid: RUNNER_OWNER_PID, ownerStartTime: RUNNER_OWNER_START_TIME, - ownerStateDir: process.env.AGENT_DEVICE_STATE_DIR, + ownerStateDir: readCurrentStateDir(), sessionId: params.sessionId, runnerPid: params.runnerPid ?? null, port: params.port, @@ -139,11 +139,16 @@ export async function prepareRunnerLeaseForStartup( } function isSameStateDirRunnerLease(lease: RunnerLease): boolean { - const currentStateDir = process.env.AGENT_DEVICE_STATE_DIR?.trim(); + // Same-state reclaim assumes callers sharing AGENT_DEVICE_STATE_DIR are the same logical daemon owner. + const currentStateDir = readCurrentStateDir(); if (!currentStateDir || !lease.ownerStateDir) return false; return path.resolve(currentStateDir) === path.resolve(lease.ownerStateDir); } +function readCurrentStateDir(): string | undefined { + return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; +} + function buildBusyRunnerLeaseHint(lease: RunnerLease): string { const owner = `PID ${lease.ownerPid}`; const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : ''; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index fab35f201..e6fa60171 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1126,11 +1126,12 @@ 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, /Cloud\/Linux clients can use iOS simulators/); + 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, /choose one explicit --session/); - assert.match(usageText, /do not use connect or --remote-config/); - assert.match(usageText, /Cloud\/remote-config profiles are a separate 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, /app-owned back uses back/); assert.match(usageText, /Web browser sessions: read help web/); assert.match( diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 5690b408a..fe8443e84 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -56,7 +56,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.', - 'Cloud/Linux clients can use iOS simulators when they are connected to a Mac through agent-device proxy. A proxy URL/token means direct proxy mode: use --daemon-base-url plus --daemon-auth-token, or saved daemonBaseUrl/daemonAuthToken config; do not use connect or --remote-config. For direct proxy, choose one explicit --session and reuse it for open/snapshot/interactions/close so cwd-scoped default sessions do not diverge. Cloud/remote-config profiles are a separate mode that uses connect or --remote-config on operational commands. Do not substitute --config; --config only loads CLI defaults.', + '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.', '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.',