diff --git a/packages/contracts/src/client-device-view.ts b/packages/contracts/src/client-device-view.ts index ba339052aa..549f88529b 100644 --- a/packages/contracts/src/client-device-view.ts +++ b/packages/contracts/src/client-device-view.ts @@ -20,6 +20,10 @@ export type AgentDeviceDevice = { * Apple devices; `platform` still carries the leaf (`ios`/`macos`). */ appleOs?: AppleOS; + /** Hardware model, for example `iPhone 17 Pro` or `Pixel 9`, when discovery reports it. */ + model?: string; + /** OS version, for example `26.1` or `16`, when discovery reports it. */ + osVersion?: string; identifiers: AgentDeviceIdentifiers; /** * Present when a host-local device claim currently blocks foreign use of diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 9724055cbd..16e3c68388 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -53,6 +53,10 @@ export type DeviceInfo = { // Explicit Apple OS discriminant populated at discovery for Apple devices. // Optional so legacy records (and non-Apple platforms) remain valid. appleOs?: AppleOS; + // Presentation-only hardware model and OS version reported by discovery when the + // platform tooling exposes them; never part of device identity or selection. + model?: string; + osVersion?: string; booted?: boolean; simulatorSetPath?: string; // Internal physical-iOS execution backend selected during discovery. diff --git a/packages/platform-android/src/inventory-parsers.test.ts b/packages/platform-android/src/inventory-parsers.test.ts index 1c7c4cd44e..5f8f383c42 100644 --- a/packages/platform-android/src/inventory-parsers.test.ts +++ b/packages/platform-android/src/inventory-parsers.test.ts @@ -4,6 +4,7 @@ import { isAndroidEmulatorSerial, normalizeAndroidDeviceName, parseAndroidAvdList, + parseAndroidDeviceDescription, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, parseAndroidFeatureListForTv, @@ -33,3 +34,12 @@ test('Android inventory parsers preserve device, AVD-name, and TV detection sema assert.equal(parseAndroidTargetFromCharacteristics('phone,tablet'), null); assert.equal(parseAndroidFeatureListForTv('feature:android.hardware.type.television'), true); }); + +test('Android description parser reads the model and OS version lines and skips unset props', () => { + assert.deepEqual(parseAndroidDeviceDescription('sdk_gphone16k_arm64\r\n17\r\n'), { + model: 'sdk_gphone16k_arm64', + osVersion: '17', + }); + assert.deepEqual(parseAndroidDeviceDescription('\n16\n'), { osVersion: '16' }); + assert.deepEqual(parseAndroidDeviceDescription(''), {}); +}); diff --git a/packages/platform-android/src/inventory-parsers.ts b/packages/platform-android/src/inventory-parsers.ts index 768ace23fe..cf65fae510 100644 --- a/packages/platform-android/src/inventory-parsers.ts +++ b/packages/platform-android/src/inventory-parsers.ts @@ -27,6 +27,18 @@ export function parseAndroidDeviceEntries(rawOutput: string): AndroidDeviceEntry return devices; } +/** Reads the model and OS version lines printed by the inventory description probe, in order. */ +export function parseAndroidDeviceDescription(rawOutput: string): { + model?: string; + osVersion?: string; +} { + const [model, osVersion] = rawOutput.split(/\r?\n/).map((line) => line.trim()); + return { + ...(model ? { model } : {}), + ...(osVersion ? { osVersion } : {}), + }; +} + export function parseAndroidAvdList(rawOutput: string): string[] { return rawOutput .split('\n') diff --git a/packages/platform-android/src/inventory.test.ts b/packages/platform-android/src/inventory.test.ts index dc0adfab6b..2c3f4f50f6 100644 --- a/packages/platform-android/src/inventory.test.ts +++ b/packages/platform-android/src/inventory.test.ts @@ -14,6 +14,15 @@ const scope: PlatformRequestScope = { progress: { report: () => undefined }, }; +const RUNNING_EMULATOR_PROBES: ReadonlyArray = [ + ['ro.boot.qemu.avd_name', 'Pixel_9_Pro_XL\n'], + ['sys.boot_completed', '1\n'], + ['getprop ro.product.model; getprop ro.build.version.release', 'sdk_gphone16k_arm64\n17\n'], + ['ro.build.characteristics', 'phone\n'], + ['has-feature', 'false\n'], + ['pm\0list\0features', ''], +]; + test.each([ ['include-stopped', true], ['running-only', false], @@ -30,11 +39,8 @@ test.each([ 'List of devices attached\nemulator-5554 device model:Pixel_9_Pro_XL\nphysical-1 device model:Phone\n', ); } - if (key.includes('ro.boot.qemu.avd_name')) return result('Pixel_9_Pro_XL\n'); - if (key.includes('sys.boot_completed')) return result('1\n'); - if (key.includes('ro.build.characteristics')) return result('phone\n'); - if (key.includes('has-feature')) return result('false\n'); - if (key.includes('pm\0list\0features')) return result(''); + const probe = RUNNING_EMULATOR_PROBES.find(([fragment]) => key.includes(fragment)); + if (probe) return result(probe[1]); throw new Error(`Unexpected command: ${request.executable} ${request.args.join(' ')}`); }); @@ -44,17 +50,67 @@ test.each([ ); assert.deepEqual( - devices.map((candidate) => [candidate.id, candidate.name, candidate.booted]), + devices.map((candidate) => [ + candidate.id, + candidate.name, + candidate.booted, + candidate.model, + candidate.osVersion, + ]), [ - ['emulator-5554', 'Pixel 9 Pro XL', true], - ...(includesStoppedAvd ? [['Living_Room_TV', 'Living_Room_TV', false] as const] : []), + ['emulator-5554', 'Pixel 9 Pro XL', true, 'sdk_gphone16k_arm64', '17'], + ...(includesStoppedAvd + ? [['Living_Room_TV', 'Living_Room_TV', false, undefined, undefined] as const] + : []), ], ); - assert.ok(calls.every((call) => call.timeoutMs === 10_000)); + const isDescriptionProbe = (call: HostCommandRequest) => + call.args.some((arg) => arg.includes('ro.product.model')); + assert.deepEqual( + calls.filter(isDescriptionProbe).map((call) => call.timeoutMs), + [2_000], + ); + assert.ok( + calls.filter((call) => !isDescriptionProbe(call)).every((call) => call.timeoutMs === 10_000), + ); assert.ok(calls.every((call) => call.allowFailure === true)); }, ); +test.each([ + ['exits non-zero', async () => result('', 'error: closed', 1)], + [ + 'times out', + async () => { + throw new AppError('COMMAND_FAILED', 'adb timed out after 2000ms', { timeoutMs: 2_000 }); + }, + ], +] as const)( + 'Android inventory leaves model and OS version unset when the description probe %s', + async (_case, describeProbe) => { + const host = createHost(async (request) => { + const key = request.args.join('\0'); + if (key === 'devices\0-l') { + return result('List of devices attached\nR5CT1 device model:SM_S921B\n'); + } + if (key.includes('ro.product.model')) return await describeProbe(); + const probe = RUNNING_EMULATOR_PROBES.find(([fragment]) => key.includes(fragment)); + if (probe) return result(probe[1]); + throw new Error(`Unexpected command: ${request.executable} ${request.args.join(' ')}`); + }); + + const [device] = await createAndroidInventory(host).discover( + { androidAvdSelection: 'running-only' }, + scope, + ); + + assert.equal(device?.name, 'SM S921B'); + assert.equal(device?.booted, true); + assert.equal(device?.model, undefined); + assert.equal(device?.osVersion, undefined); + }, +); + test('Android inventory fails closed when adb is unavailable', async () => { const host = createHost(async () => result(''), { adb: undefined }); await assert.rejects( diff --git a/packages/platform-android/src/inventory.ts b/packages/platform-android/src/inventory.ts index 6341f0d6ff..9d8de97079 100644 --- a/packages/platform-android/src/inventory.ts +++ b/packages/platform-android/src/inventory.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { deviceShellArgv, type ShellWord } from '@agent-device/kernel/device-shell'; +import { deviceShellArgv, shellFragment, type ShellWord } from '@agent-device/kernel/device-shell'; import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { DeviceInventoryHostFor, @@ -16,6 +16,7 @@ import { isAndroidEmulatorSerial, normalizeAndroidDeviceName, parseAndroidAvdList, + parseAndroidDeviceDescription, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, parseAndroidFeatureListForTv, @@ -24,6 +25,10 @@ import { } from './inventory-parsers.ts'; const PROBE_TIMEOUT_MS = 10_000; +const DEVICE_DESCRIPTION_TIMEOUT_MS = 2_000; +const DEVICE_DESCRIPTION_PROBE = shellFragment( + 'getprop ro.product.model; getprop ro.build.version.release', +); const TV_FEATURES = [ 'android.software.leanback', 'android.software.leanback_only', @@ -119,10 +124,11 @@ async function probeRunningDevice( context: AndroidInventoryContext, entry: AndroidDeviceEntry, ): Promise { - const [name, booted, target] = await Promise.all([ + const [name, booted, target, description] = await Promise.all([ resolveDeviceName(context, entry), isBooted(context, entry.serial), resolveTarget(context, entry.serial), + readDeviceDescription(context, entry.serial), ]); return { platform: 'android', @@ -130,10 +136,30 @@ async function probeRunningDevice( name, kind: isAndroidEmulatorSerial(entry.serial) ? 'emulator' : 'device', target, + ...description, booted, }; } +/** Best-effort model and OS version read in one shell call; a failed probe leaves both unset. */ +async function readDeviceDescription( + context: AndroidInventoryContext, + serial: string, +): Promise> { + try { + const result = await runAdbShell( + context, + serial, + [DEVICE_DESCRIPTION_PROBE], + DEVICE_DESCRIPTION_TIMEOUT_MS, + ); + return result.exitCode === 0 ? parseAndroidDeviceDescription(result.stdout) : {}; + } catch (error) { + if (context.scope.signal.aborted) throw error; + return {}; + } +} + async function resolveDeviceName( context: AndroidInventoryContext, entry: AndroidDeviceEntry, @@ -244,14 +270,16 @@ async function runAdbShell( context: AndroidInventoryContext, serial: string, words: readonly ShellWord[], + timeoutMs = PROBE_TIMEOUT_MS, ): Promise { - return await run(context, context.adb, adbShellArgv(serial, words)); + return await run(context, context.adb, adbShellArgv(serial, words), timeoutMs); } async function run( context: AndroidInventoryContext, executable: string, args: readonly string[], + timeoutMs = PROBE_TIMEOUT_MS, ): Promise { try { return await context.host.commands.run( @@ -259,7 +287,7 @@ async function run( executable, args, allowFailure: true, - timeoutMs: PROBE_TIMEOUT_MS, + timeoutMs, }, context.scope.signal, ); diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index a7dc9d0643..b82cd4b3f9 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -7,7 +7,7 @@ import { readSimctlDeviceState, scopeSimctlArgsForDevice, simctlAvailabilityProbeArgs, - simctlListDevicesArgs, + simctlListInventoryArgs, simulatorAddressFor, type SimulatorAddress, } from '../simctl.ts'; @@ -60,16 +60,10 @@ test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => { assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']); }); -test('simctlListDevicesArgs prefixes a trimmed simulator set and omits a blank one', () => { - assert.deepEqual(simctlListDevicesArgs(' /tmp/set '), [ - '--set', - '/tmp/set', - 'list', - 'devices', - '-j', - ]); - assert.deepEqual(simctlListDevicesArgs(' '), ['list', 'devices', '-j']); - assert.deepEqual(simctlListDevicesArgs(undefined), ['list', 'devices', '-j']); +test('simctlListInventoryArgs prefixes a trimmed simulator set and omits a blank one', () => { + assert.deepEqual(simctlListInventoryArgs(' /tmp/set '), ['--set', '/tmp/set', 'list', '-j']); + assert.deepEqual(simctlListInventoryArgs(' '), ['list', '-j']); + assert.deepEqual(simctlListInventoryArgs(undefined), ['list', '-j']); }); test('simctlAvailabilityProbeArgs names no set', () => { diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index 96f6df722a..0d2ab26236 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -35,9 +35,12 @@ export function simctlAvailabilityProbeArgs(): ScopedSimctlArgs { return scopeSimctlArgs(undefined, ['help']); } -/** `simctl list devices -j` over one simulator set; `undefined` lists the default set. */ -export function simctlListDevicesArgs(simulatorSetPath: string | undefined): ScopedSimctlArgs { - return scopeSimctlArgs(simulatorSetPath, ['list', 'devices', '-j']); +/** + * `simctl list -j` over one simulator set; `undefined` lists the default set. The unfiltered + * listing carries devices together with the device types and runtimes that describe them. + */ +export function simctlListInventoryArgs(simulatorSetPath: string | undefined): ScopedSimctlArgs { + return scopeSimctlArgs(simulatorSetPath, ['list', '-j']); } /** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */ diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index f43bebedbb..bc72daa436 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -62,3 +62,27 @@ test('devicectl records map to normalized physical Apple devices', () => { null, ); }); + +test('devicectl records report the marketing model and OS version', () => { + const devicectlRecord = { + identifier: '3F1DAD0D-212F-44F2-B41E-C390F96140BE', + hardwareProperties: { + deviceType: 'iPhone', + marketingName: 'iPhone 17 Pro', + platform: 'iOS', + productType: 'iPhone18,1', + udid: '00008150-000A1C2E3F40001E', + }, + deviceProperties: { + bootState: 'booted', + name: 'Oskar iPhone', + osBuildUpdate: '23F77', + osVersionNumber: '26.5', + }, + connectionProperties: { pairingState: 'paired', tunnelState: 'connected' }, + }; + const device = mapDevicectlAppleDevice(devicectlRecord); + assert.equal(device?.name, 'Oskar iPhone'); + assert.equal(device?.model, 'iPhone 17 Pro'); + assert.equal(device?.osVersion, '26.5'); +}); diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 3e74b49add..77de4771c9 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -10,8 +10,18 @@ const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const; export type DevicectlAppleDevice = { identifier?: string; name?: string; - hardwareProperties?: { platform?: string; udid?: string; productType?: string }; - deviceProperties?: { name?: string; productType?: string; deviceType?: string }; + hardwareProperties?: { + platform?: string; + udid?: string; + productType?: string; + marketingName?: string; + }; + deviceProperties?: { + name?: string; + productType?: string; + deviceType?: string; + osVersionNumber?: string; + }; connectionProperties?: { tunnelState?: string }; }; @@ -106,6 +116,8 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf const name = device.name ?? device.deviceProperties?.name ?? id; if (!id) return null; const target = resolveAppleTargetFromDevicectlDevice(device); + const model = device.hardwareProperties?.marketingName?.trim(); + const osVersion = device.deviceProperties?.osVersionNumber?.trim(); return { platform: 'apple', id, @@ -113,6 +125,8 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf kind: 'device', target, appleOs: resolveAppleOs(target, [devicectlProductType(device), ...devicectlLabels(device)]), + ...(model ? { model } : {}), + ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'coredevice', booted: true, }; diff --git a/packages/platform-apple/src/inventory.test.ts b/packages/platform-apple/src/inventory.test.ts index edbd247d93..4c6401eb90 100644 --- a/packages/platform-apple/src/inventory.test.ts +++ b/packages/platform-apple/src/inventory.test.ts @@ -114,7 +114,7 @@ test('simulator-only inventory avoids physical discovery and observes fresh boot ['sim-1'], ); assert.deepEqual(observed, ['sim-1']); - assert.deepEqual(calls, [['simctl', 'list', 'devices', '-j']]); + assert.deepEqual(calls, [['simctl', 'list', '-j']]); }); test('simulator-set inventory remains scoped while retaining the host Mac', async () => { @@ -148,7 +148,7 @@ test('simulator-set inventory remains scoped while retaining the host Mac', asyn devices.map((device) => device.id), ['tv-1', 'host-macos-local'], ); - assert.deepEqual(calls, [['simctl', '--set', '/tmp/custom-set', 'list', 'devices', '-j']]); + assert.deepEqual(calls, [['simctl', '--set', '/tmp/custom-set', 'list', '-j']]); }); test('unscoped Apple inventory starts simulator and physical discovery concurrently', async () => { diff --git a/packages/platform-apple/src/physical-inventory.test.ts b/packages/platform-apple/src/physical-inventory.test.ts index afa4c25256..5b34cf8a53 100644 --- a/packages/platform-apple/src/physical-inventory.test.ts +++ b/packages/platform-apple/src/physical-inventory.test.ts @@ -27,6 +27,19 @@ test('xctrace parser accepts Apple devices only from the online Devices section' ); }); +test('xctrace parser reports the OS version printed beside a physical device', () => { + const [device] = parseXctracePhysicalAppleDevices( + [ + '== Devices ==', + 'Oskar’s Mac Mini (A80D8E51-93C6-5832-A2EC-FAF18C7F6B6C)', + 'Oskar’s iPhone (18.6) (00008110-001A2B3C4D5E801E)', + ].join('\n'), + ); + assert.equal(device?.id, '00008110-001A2B3C4D5E801E'); + assert.equal(device?.osVersion, '18.6'); + assert.equal(device?.model, undefined); +}); + test('physical inventory prefers CoreDevice, supplements xctrace, and disposes its file', async () => { const calls: string[][] = []; let disposeCount = 0; diff --git a/packages/platform-apple/src/physical-inventory.ts b/packages/platform-apple/src/physical-inventory.ts index 63e6f889fb..bd521b3b86 100644 --- a/packages/platform-apple/src/physical-inventory.ts +++ b/packages/platform-apple/src/physical-inventory.ts @@ -53,6 +53,7 @@ function parseXctracePhysicalAppleDeviceLine(line: string): DeviceInfo | undefin kind: 'device', target, appleOs: resolveAppleOs(target, osVersion ? [name, osVersion] : [name]), + ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'xctest', booted: true, }; diff --git a/packages/platform-apple/src/simulator-inventory.test.ts b/packages/platform-apple/src/simulator-inventory.test.ts index 17ab04e6bd..eb2103f94d 100644 --- a/packages/platform-apple/src/simulator-inventory.test.ts +++ b/packages/platform-apple/src/simulator-inventory.test.ts @@ -85,6 +85,43 @@ test('simctl parser classifies visionOS from the runtime when the name is not de assert.equal(device?.appleOs, 'visionos'); }); +test('simctl parser describes simulators from the listed device types and runtimes', () => { + const [renamed, unknownType] = parseSimctlAppleDevices( + { + devicetypes: [ + { + identifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro', + name: 'iPhone 18 Pro', + }, + ], + runtimes: [{ identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-0', version: '27.0' }], + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-27-0': [ + { + name: 'bsky-e2e', + udid: '6603BA4F-E38F-46BA-990E-02C685BA5E6E', + state: 'Shutdown', + isAvailable: true, + deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro', + }, + { + name: 'Retired type', + udid: 'retired-1', + state: 'Shutdown', + isAvailable: true, + deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-X', + }, + ], + }, + }, + undefined, + ); + assert.equal(renamed?.model, 'iPhone 18 Pro'); + assert.equal(renamed?.osVersion, '27.0'); + assert.equal(unknownType?.model, undefined); + assert.equal(unknownType?.osVersion, '27.0'); +}); + test('simulator inventory scopes bounded simctl and reports fresh booted observations', async () => { const calls: Array<{ tool: string; args: readonly string[]; timeoutMs?: number }> = []; const observed: string[] = []; @@ -105,7 +142,7 @@ test('simulator inventory scopes bounded simctl and reports fresh booted observa assert.deepEqual(calls, [ { tool: 'simctl', - args: ['--set', '/tmp/custom-set', 'list', 'devices', '-j'], + args: ['--set', '/tmp/custom-set', 'list', '-j'], timeoutMs: 3_000, }, ]); diff --git a/packages/platform-apple/src/simulator-inventory.ts b/packages/platform-apple/src/simulator-inventory.ts index 1c2a57730a..bc9a7162f8 100644 --- a/packages/platform-apple/src/simulator-inventory.ts +++ b/packages/platform-apple/src/simulator-inventory.ts @@ -5,7 +5,7 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import { sortAppleDevicesForSelection, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { simctlListDevicesArgs } from './core/simctl.ts'; +import { simctlListInventoryArgs } from './core/simctl.ts'; import { isSupportedAppleRuntime, resolveAppleOs, @@ -20,16 +20,19 @@ type SimctlDeviceRecord = { deviceTypeIdentifier?: string; }; -type SimctlListDevicesPayload = { +type SimctlListPayload = { devices: Record; + devicetypes?: Array<{ identifier: string; name: string }>; + runtimes?: Array<{ identifier: string; version: string }>; }; const BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; export function parseSimctlAppleDevices( - payload: SimctlListDevicesPayload, + payload: SimctlListPayload, simulatorSetPath: string | undefined, ): DeviceInfo[] { + const describe = simulatorDescriber(payload); const devices: DeviceInfo[] = []; for (const [runtime, runtimes] of Object.entries(payload.devices)) { if (!isSupportedAppleRuntime(runtime)) continue; @@ -43,6 +46,7 @@ export function parseSimctlAppleDevices( kind: 'simulator', target, appleOs: resolveAppleOs(target, [runtime, device.deviceTypeIdentifier ?? '', device.name]), + ...describe(runtime, device), booted: device.state === 'Booted', ...(simulatorSetPath ? { simulatorSetPath } : {}), }); @@ -51,6 +55,22 @@ export function parseSimctlAppleDevices( return devices; } +/** Resolves a simulator's model and OS version from the device types and runtimes in the listing. */ +function simulatorDescriber( + payload: SimctlListPayload, +): (runtime: string, device: SimctlDeviceRecord) => Pick { + const models = new Map(payload.devicetypes?.map((type) => [type.identifier, type.name])); + const osVersions = new Map(payload.runtimes?.map((entry) => [entry.identifier, entry.version])); + return (runtime, device) => { + const model = models.get(device.deviceTypeIdentifier ?? ''); + const osVersion = osVersions.get(runtime); + return { + ...(model ? { model } : {}), + ...(osVersion ? { osVersion } : {}), + }; + }; +} + export async function listAppleSimulators( host: DeviceInventoryHostFor<'apple'>, request: Readonly, @@ -60,14 +80,14 @@ export async function listAppleSimulators( const result = await host.appleTools.run( { tool: 'simctl', - args: simctlListDevicesArgs(simulatorSetPath), + args: simctlListInventoryArgs(simulatorSetPath), ...(request.booted === true ? { timeoutMs: BOOTED_SIMULATOR_PROBE_TIMEOUT_MS } : {}), }, scope.signal, ); let devices: DeviceInfo[]; try { - const parsed = JSON.parse(result.stdout) as SimctlListDevicesPayload; + const parsed = JSON.parse(result.stdout) as SimctlListPayload; devices = parseSimctlAppleDevices(parsed, simulatorSetPath); } catch (error) { throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, error); diff --git a/src/client/client-normalizers.test.ts b/src/client/client-normalizers.test.ts index 09b286295e..cbb516bacb 100644 --- a/src/client/client-normalizers.test.ts +++ b/src/client/client-normalizers.test.ts @@ -64,3 +64,20 @@ test('device normalization preserves the projected claim owner and drops malform expect(normalizeDevice(base).claimedBy).toBeUndefined(); expect(normalizeDevice({ ...base, claimedBy: { session: 42 } }).claimedBy).toBeUndefined(); }); + +test('device normalization carries the listed model and OS version and drops malformed ones', () => { + const base = { + platform: 'android', + target: 'mobile', + kind: 'device', + id: 'R5CT1', + name: 'Pixel 9', + booted: true, + }; + const described = normalizeDevice({ ...base, model: 'Pixel 9', osVersion: '16' }); + expect(described).toMatchObject({ model: 'Pixel 9', osVersion: '16' }); + + const malformed = normalizeDevice({ ...base, model: '', osVersion: 16 }); + expect(malformed).not.toHaveProperty('model'); + expect(malformed).not.toHaveProperty('osVersion'); +}); diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index 2659b7c57e..ccd0a3d29c 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -111,12 +111,25 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice { // Additive Apple-OS discriminant; Apple platforms only — gate on the platform so // a non-Apple record with a stray appleOs value is not preserved. ...(isApplePlatform(platform) && appleOs ? { appleOs } : {}), + ...readDeviceDescription(record), identifiers: buildDeviceIdentifiers(platform, id, name), ...readClaimedBy(record), ...buildClientDevicePlatformFields(platform, id), }; } +/** Reads the optional presentation-only hardware model and OS version of a listed device. */ +function readDeviceDescription( + record: Record, +): Pick { + const model = readOptionalString(record, 'model'); + const osVersion = readOptionalString(record, 'osVersion'); + return { + ...(model ? { model } : {}), + ...(osVersion ? { osVersion } : {}), + }; +} + function readClaimedBy(record: Record): Pick { const value = record.claimedBy; if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 9b974c3e4a..2af7d72c68 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -375,4 +375,30 @@ describe('devices output', () => { expect(lines[0]).not.toContain('claimed by'); expect(lines[1]).toContain('claimed by session "qa" in /worktrees/qa'); }); + + test('carries the discovered model and OS version through JSON data and the text line', async () => { + const output = await managementCliOutputFormatters.devices({ + input: {}, + result: [ + { + platform: 'ios', + appleOs: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'sim-1', + name: 'bsky-e2e', + model: 'iPhone 17 Pro', + osVersion: '26.5', + booted: true, + identifiers: { deviceId: 'sim-1', deviceName: 'bsky-e2e', udid: 'sim-1' }, + }, + ], + }); + + const devices = (output.data as { devices: Record[] }).devices; + expect(devices[0]).toMatchObject({ model: 'iPhone 17 Pro', osVersion: '26.5' }); + expect(output.text).toBe( + 'bsky-e2e (ios simulator target=mobile model="iPhone 17 Pro" os=26.5) booted=true', + ); + }); }); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 70b9f96cb5..19086385f4 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -235,11 +235,13 @@ export const managementCliOutputFormatters = { function formatDeviceLine(device: AgentDeviceDevice): string { const kind = device.kind ? ` ${device.kind}` : ''; const target = device.target ? ` target=${device.target}` : ''; + const model = device.model ? ` model=${JSON.stringify(device.model)}` : ''; + const osVersion = device.osVersion ? ` os=${device.osVersion}` : ''; const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; const claimed = device.claimedBy ? ` claimed by session "${device.claimedBy.session}" in ${device.claimedBy.workspace}` : ''; - return `${device.name} (${device.platform}${kind}${target})${booted}${claimed}`; + return `${device.name} (${device.platform}${kind}${target}${model}${osVersion})${booted}${claimed}`; } function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts'][number]): string { diff --git a/src/commands/output/result-serialization.ts b/src/commands/output/result-serialization.ts index bf1bfd9ec3..8df96ab13c 100644 --- a/src/commands/output/result-serialization.ts +++ b/src/commands/output/result-serialization.ts @@ -63,6 +63,8 @@ export function serializeDevice(device: AgentDeviceDevice): Record arg.includes("ro.product.model") && arg.includes("ro.build.version.release"))', + String.raw` ? "sdk_gphone64_arm64\n16\n"`, ' : args.includes("has-feature") || args.includes("pm")', String.raw` ? "false\n"`, ' : JSON.stringify({args, port: process.env.ANDROID_ADB_SERVER_PORT ?? null});', @@ -180,6 +182,8 @@ test.skipIf(process.platform === 'win32')( name: 'Managed Pixel', kind: 'emulator', target: 'mobile', + model: 'sdk_gphone64_arm64', + osVersion: '16', booted: true, }, ], diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index b9166cef34..7ce8f9d2c3 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -241,7 +241,8 @@ export function simctlListDevicesResult( runtime: string, devices: Array<{ name: string; udid: string; state?: string; isAvailable?: boolean }>, ): ExecResult | undefined { - if (args.join(' ') !== 'list devices -j') { + const listing = args.join(' '); + if (listing !== 'list devices -j' && listing !== 'list -j') { return undefined; } return simctlListDevicesJson(runtime, devices); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9265e32296..a8ff245622 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -341,6 +341,8 @@ The complete domain-client method map is: - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +`client.devices.list()` returns `AgentDeviceDevice` entries. Their optional `model` and `osVersion` fields describe the hardware and OS when discovery reports them; see [Device discovery](/docs/commands#device-discovery) for the sources. + `client.observability.events({ cursor, limit })` reads the session event timeline as paged JSON entries. Use `nextCursor` from the previous page to continue from the daemon-owned `events.ndjson` file without replaying already uploaded/displayed events. Cursors are absolute and survive the file's size rotation; a cursor older than the retained window rejects with `COMMAND_FAILED`, `details.reason: "EVENT_LOG_CURSOR_EXPIRED"`, and `details.earliestCursor` to resume from. The event timeline keeps operational context such as command/status/timing, paths, session/device/app identifiers, refs/selectors, and coordinates. Typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw message fragments are replaced with length-only placeholders. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bccb7b062a..dd197263af 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -237,6 +237,7 @@ agent-device capabilities --session checkout --json - `devices` lists available targets after applying any platform selector or isolation scope flags. - Use `--platform` to narrow discovery to Apple-family (`ios`, `tvOS`, `macOS`), Android, HarmonyOS, or Vega OS targets. - Use `--ios-simulator-device-set` and `--android-device-allowlist` when you need tenant- or lab-scoped discovery. +- Each listed device carries `model` (hardware model, for example `iPhone 17 Pro` or `Pixel 9`) and `osVersion` (for example `26.1` or `16`) when the platform tooling reports them. iOS simulators read them from `simctl` device types and runtimes, physical iOS devices from `devicectl` (or the OS version printed by `xctrace`), and running Android devices from `ro.product.model` and `ro.build.version.release`; Android emulators report the system image model, such as `sdk_gphone64_arm64`. Stopped AVDs and other platforms omit both fields. They describe a device only and are not selectors. - `capabilities` reports the command names supported by the selected session device or an explicit `--platform`/`--device`/`--udid`/`--serial` target. - In JSON output, `capabilities` returns `{ device, availableCommands }`. Use `availableCommands` for dynamic integrations instead of maintaining a separate platform support table.