From dd9854dbf7ee495fcaacb30083f581b5fe011f25 Mon Sep 17 00:00:00 2001 From: gronxb Date: Wed, 4 Nov 2026 12:34:46 +0900 Subject: [PATCH 1/5] fix(ios): report devices CoreDevice cannot reach as not booted devicectl lists every paired device, and the inventory mapped each one as booted, so a phone that was unplugged and off the network was listed as booted and passed boot. The first command that failed was the app launch. The devicectl mapping now reads connectionProperties.tunnelState and reports an unavailable device as not booted, and CoreDevice readiness refuses it as not_ready with a hint to connect it. Fixes #4209 --- .../__tests__/physical-device-coredevice.test.ts | 16 ++++++++++++++++ .../src/core/physical-device-coredevice.ts | 8 ++++++++ .../src/inventory-classification.test.ts | 12 ++++++++++++ .../src/inventory-classification.ts | 2 +- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts index bd99a2309e..7cf98c4203 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts @@ -589,6 +589,22 @@ test('ensureCoreDeviceReady names the connecting tunnel state as the reason it r assert.match(error.message, /not ready for automation/i); }); +test('ensureCoreDeviceReady refuses a device CoreDevice cannot reach', async () => { + const error = await expectReadyFailure(() => readyPayload('unavailable')); + assert.equal(error.details?.kind, 'not_ready'); + assert.equal(error.details?.tunnelState, 'unavailable'); + assert.match(error.message, /not ready for automation/i); +}); + +test('ensureCoreDeviceReady passes a device whose tunnel opens on demand', async () => { + await withCoreDeviceProbe( + () => readyPayload('disconnected'), + async () => { + await ensureCoreDeviceReady(IOS_DEVICE); + }, + ); +}); + test('ensureCoreDeviceReady reports a failed details command with the device hint', async () => { const error = await expectReadyFailure(() => ({ exitCode: 1, diff --git a/packages/platform-apple/src/core/physical-device-coredevice.ts b/packages/platform-apple/src/core/physical-device-coredevice.ts index 7600e7eea1..1ba7bfea8e 100644 --- a/packages/platform-apple/src/core/physical-device-coredevice.ts +++ b/packages/platform-apple/src/core/physical-device-coredevice.ts @@ -72,6 +72,14 @@ export async function ensureCoreDeviceReady( hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', }); } + if (tunnelState === 'unavailable') { + throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { + kind: 'not_ready', + deviceId: device.id, + tunnelState, + hint: 'CoreDevice cannot reach the device. Connect it by cable or to the same network as this Mac, unlock it, and wait until Xcode Devices lists it as available, then retry.', + }); + } return; } throw new AppError( diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index bc72daa436..1883019f12 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -63,6 +63,18 @@ test('devicectl records map to normalized physical Apple devices', () => { ); }); +test('devicectl records report a paired device CoreDevice cannot reach as not booted', () => { + const record = (tunnelState?: string) => ({ + hardwareProperties: { platform: 'iOS', productType: 'iPhone18,1', udid: 'phone-1' }, + ...(tunnelState === undefined ? {} : { connectionProperties: { tunnelState } }), + }); + assert.equal(mapDevicectlAppleDevice(record('unavailable'))?.booted, false); + // CoreDevice opens a tunnel on demand, so an idle device it can reach is still usable. + assert.equal(mapDevicectlAppleDevice(record('disconnected'))?.booted, true); + assert.equal(mapDevicectlAppleDevice(record('connected'))?.booted, true); + assert.equal(mapDevicectlAppleDevice(record())?.booted, true); +}); + test('devicectl records report the marketing model and OS version', () => { const devicectlRecord = { identifier: '3F1DAD0D-212F-44F2-B41E-C390F96140BE', diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 77de4771c9..8eaa9c78b4 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -128,6 +128,6 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf ...(model ? { model } : {}), ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'coredevice', - booted: true, + booted: device.connectionProperties?.tunnelState !== 'unavailable', }; } From 5d2ad92267b5e6b0b1e4ff945e412a94dc33bb0e Mon Sep 17 00:00:00 2001 From: gronxb Date: Wed, 4 Nov 2026 12:41:13 +0900 Subject: [PATCH 2/5] docs(ios): describe paired devices CoreDevice cannot reach The physical-device help and the installation page now say how devices and readiness report a paired device that CoreDevice cannot reach. --- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/installation.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 47b34af2b6..961523746b 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -457,7 +457,7 @@ Discovery: iOS physical-device prerequisites: Xcode, xcrun xcdevice, and xcrun xctrace must be available from the selected Xcode. - The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled. + The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled. A paired device that CoreDevice cannot reach (devicectl tunnelState unavailable: unplugged or off the network) is listed with booted: false, and commands refuse it with details.kind not_ready and details.tunnelState unavailable. Modern devices visible to devicectl use CoreDevice. Older devices visible only to xctrace use the XCTest backend automatically. XCTest-backed devices must already have the target app installed and should be opened by bundle ID; app inventory, install/reinstall, logs, performance sampling, recording, deep links, and launch arguments require CoreDevice. XCTest-backed runner commands travel through macOS usbmuxd; keep the trusted device connected by cable. diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index ae7cc70a55..bd9afb69dc 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -113,6 +113,7 @@ Run `agent-device help physical-device` for the same setup guidance from the CLI - Xcode is installed with `xcrun devicectl` and `xcrun xctrace` available. - The device is paired, trusted, connected, unlocked when needed, and listed by `xcrun devicectl list devices`. + A paired device that CoreDevice cannot reach (`tunnelState: unavailable`, for example unplugged and off the network) is listed by `devices` with `booted: false`. Commands refuse it with `details.kind: not_ready` and `details.tunnelState: unavailable`. - Developer Mode is on in the device's Settings. - The Apple runner must be signed before commands can run on the device. Start with Automatic Signing in Xcode and these environment variables; add the others below only when needed: - `AGENT_DEVICE_IOS_TEAM_ID` From c7e101ec5e74cdbae3d81f4fa77f9f0e619b8467 Mon Sep 17 00:00:00 2001 From: gronxb Date: Wed, 4 Nov 2026 13:11:29 +0900 Subject: [PATCH 3/5] refactor(ios): classify an unreachable CoreDevice tunnel in one place The inventory mapping and the readiness check each compared the tunnel state to 'unavailable', and only readiness lowercased it first. Both now call isCoreDeviceUnreachable, which lives beside the readiness constants so neither eager import closure grows. --- packages/platform-apple/src/core/physical-device-constants.ts | 4 ++++ .../platform-apple/src/core/physical-device-coredevice.ts | 3 ++- packages/platform-apple/src/inventory-classification.test.ts | 1 + packages/platform-apple/src/inventory-classification.ts | 3 ++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/core/physical-device-constants.ts b/packages/platform-apple/src/core/physical-device-constants.ts index 3a61cce420..d038afde3d 100644 --- a/packages/platform-apple/src/core/physical-device-constants.ts +++ b/packages/platform-apple/src/core/physical-device-constants.ts @@ -1,2 +1,6 @@ export const IOS_DEVICE_READY_TIMEOUT_MS = 15_000; export const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; + +export function isCoreDeviceUnreachable(tunnelState: string | undefined): boolean { + return tunnelState?.toLowerCase() === 'unavailable'; +} diff --git a/packages/platform-apple/src/core/physical-device-coredevice.ts b/packages/platform-apple/src/core/physical-device-coredevice.ts index 1ba7bfea8e..2831507e5e 100644 --- a/packages/platform-apple/src/core/physical-device-coredevice.ts +++ b/packages/platform-apple/src/core/physical-device-coredevice.ts @@ -19,6 +19,7 @@ import { import { IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, IOS_DEVICE_READY_TIMEOUT_MS, + isCoreDeviceUnreachable, } from './physical-device-constants.ts'; const IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; @@ -72,7 +73,7 @@ export async function ensureCoreDeviceReady( hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', }); } - if (tunnelState === 'unavailable') { + if (isCoreDeviceUnreachable(tunnelState)) { throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { kind: 'not_ready', deviceId: device.id, diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index 1883019f12..6d22c36ecd 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -69,6 +69,7 @@ test('devicectl records report a paired device CoreDevice cannot reach as not bo ...(tunnelState === undefined ? {} : { connectionProperties: { tunnelState } }), }); assert.equal(mapDevicectlAppleDevice(record('unavailable'))?.booted, false); + assert.equal(mapDevicectlAppleDevice(record('Unavailable'))?.booted, false); // CoreDevice opens a tunnel on demand, so an idle device it can reach is still usable. assert.equal(mapDevicectlAppleDevice(record('disconnected'))?.booted, true); assert.equal(mapDevicectlAppleDevice(record('connected'))?.booted, true); diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 8eaa9c78b4..4bc41dafb4 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -1,4 +1,5 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; +import { isCoreDeviceUnreachable } from './core/physical-device-constants.ts'; const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; const APPLE_IPAD_PATTERN = /ipad/i; @@ -128,6 +129,6 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf ...(model ? { model } : {}), ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'coredevice', - booted: device.connectionProperties?.tunnelState !== 'unavailable', + booted: !isCoreDeviceUnreachable(device.connectionProperties?.tunnelState), }; } From b814e92fc3874ffc7478a7d5fb9bad50a96a9c68 Mon Sep 17 00:00:00 2001 From: gronxb Date: Wed, 4 Nov 2026 13:36:59 +0900 Subject: [PATCH 4/5] fix(ios): ignore a non-string CoreDevice tunnel state The inventory reads devicectl JSON without validating it at runtime, so isCoreDeviceUnreachable lowercased whatever tunnelState held and would throw on a non-string, breaking the whole device listing. It now returns false for anything that is not a string. --- .../platform-apple/src/core/physical-device-constants.ts | 4 ++-- .../platform-apple/src/inventory-classification.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/core/physical-device-constants.ts b/packages/platform-apple/src/core/physical-device-constants.ts index d038afde3d..15eba9dfaa 100644 --- a/packages/platform-apple/src/core/physical-device-constants.ts +++ b/packages/platform-apple/src/core/physical-device-constants.ts @@ -1,6 +1,6 @@ export const IOS_DEVICE_READY_TIMEOUT_MS = 15_000; export const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; -export function isCoreDeviceUnreachable(tunnelState: string | undefined): boolean { - return tunnelState?.toLowerCase() === 'unavailable'; +export function isCoreDeviceUnreachable(tunnelState: unknown): boolean { + return typeof tunnelState === 'string' && tunnelState.toLowerCase() === 'unavailable'; } diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index 6d22c36ecd..6c643a6fee 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -74,6 +74,14 @@ test('devicectl records report a paired device CoreDevice cannot reach as not bo assert.equal(mapDevicectlAppleDevice(record('disconnected'))?.booted, true); assert.equal(mapDevicectlAppleDevice(record('connected'))?.booted, true); assert.equal(mapDevicectlAppleDevice(record())?.booted, true); + // devicectl JSON is not validated at runtime, so a malformed state must not break the listing. + assert.equal( + mapDevicectlAppleDevice({ + hardwareProperties: { platform: 'iOS', productType: 'iPhone18,1', udid: 'phone-1' }, + connectionProperties: { tunnelState: 3 as unknown as string }, + })?.booted, + true, + ); }); test('devicectl records report the marketing model and OS version', () => { From 73c49187d8c2d9f17548f7c3c6894ebcd1d1d65c Mon Sep 17 00:00:00 2001 From: gronxb Date: Wed, 4 Nov 2026 14:33:45 +0900 Subject: [PATCH 5/5] refactor(ios): name the CoreDevice tunnel classifier for reachability isCoreDeviceReachable states the positive case, which is what booted reports, so the inventory assigns it directly and readiness negates it. --- packages/platform-apple/src/core/physical-device-constants.ts | 4 ++-- .../platform-apple/src/core/physical-device-coredevice.ts | 4 ++-- packages/platform-apple/src/inventory-classification.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/platform-apple/src/core/physical-device-constants.ts b/packages/platform-apple/src/core/physical-device-constants.ts index 15eba9dfaa..f981f0c511 100644 --- a/packages/platform-apple/src/core/physical-device-constants.ts +++ b/packages/platform-apple/src/core/physical-device-constants.ts @@ -1,6 +1,6 @@ export const IOS_DEVICE_READY_TIMEOUT_MS = 15_000; export const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; -export function isCoreDeviceUnreachable(tunnelState: unknown): boolean { - return typeof tunnelState === 'string' && tunnelState.toLowerCase() === 'unavailable'; +export function isCoreDeviceReachable(tunnelState: unknown): boolean { + return typeof tunnelState !== 'string' || tunnelState.toLowerCase() !== 'unavailable'; } diff --git a/packages/platform-apple/src/core/physical-device-coredevice.ts b/packages/platform-apple/src/core/physical-device-coredevice.ts index 2831507e5e..f7e7551b39 100644 --- a/packages/platform-apple/src/core/physical-device-coredevice.ts +++ b/packages/platform-apple/src/core/physical-device-coredevice.ts @@ -19,7 +19,7 @@ import { import { IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, IOS_DEVICE_READY_TIMEOUT_MS, - isCoreDeviceUnreachable, + isCoreDeviceReachable, } from './physical-device-constants.ts'; const IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; @@ -73,7 +73,7 @@ export async function ensureCoreDeviceReady( hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', }); } - if (isCoreDeviceUnreachable(tunnelState)) { + if (!isCoreDeviceReachable(tunnelState)) { throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { kind: 'not_ready', deviceId: device.id, diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 4bc41dafb4..2d1ba61018 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -1,5 +1,5 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; -import { isCoreDeviceUnreachable } from './core/physical-device-constants.ts'; +import { isCoreDeviceReachable } from './core/physical-device-constants.ts'; const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; const APPLE_IPAD_PATTERN = /ipad/i; @@ -129,6 +129,6 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf ...(model ? { model } : {}), ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'coredevice', - booted: !isCoreDeviceUnreachable(device.connectionProperties?.tunnelState), + booted: isCoreDeviceReachable(device.connectionProperties?.tunnelState), }; }