From abebc08c503cdf336545b691c7812037e4b4589f Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 14:03:34 -0700 Subject: [PATCH 01/15] feat: add wearable pairing command --- .../src/__tests__/device-claim-policy.test.ts | 1 + .../src/flag-definitions-target.ts | 9 + packages/command-registry/src/registry.ts | 18 ++ packages/contracts/package.json | 4 + packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-device-view.ts | 26 +- packages/contracts/src/facades/client.ts | 2 + .../src/platform-runtime-operations.ts | 3 + .../src/platform-runtime-unavailable.ts | 3 + .../contracts/src/runtime-operation-names.ts | 1 + .../contracts/src/wearable-pairing-runtime.ts | 30 +++ packages/platform-android/src/runtime.ts | 5 + .../src/wearable-pairing.test.ts | 78 ++++++ .../platform-android/src/wearable-pairing.ts | 107 ++++++++ .../src/inventory-classification.ts | 4 +- packages/platform-apple/src/runner-demand.ts | 1 + packages/platform-apple/src/runtime.ts | 18 ++ .../src/simulator-inventory.test.ts | 14 +- .../src/wearable-pairing.test.ts | 101 ++++++++ .../platform-apple/src/wearable-pairing.ts | 239 ++++++++++++++++++ packages/platform-harmonyos/src/runtime.ts | 1 + packages/platform-web/src/runtime.ts | 1 + packages/provider-limrun/src/facts-runtime.ts | 1 + .../layering/contracts-exports.snapshot.json | 3 +- src/__tests__/cli-client-commands.test.ts | 1 + .../test-utils/property-arbitraries.ts | 1 + .../test-utils/runtime-operation-facts.ts | 1 + src/__tests__/wearable-pairing.coverage.ts | 8 + src/agent-device-client.ts | 19 ++ src/client/client-types.ts | 3 + src/commands/management/device.ts | 71 +++++- src/commands/management/output.ts | 10 + .../handlers/__tests__/install-source.test.ts | 1 + .../handlers/__tests__/session-state.test.ts | 205 +++++++++++++++ src/daemon/handlers/session-state.ts | 97 +++++++ src/daemon/handlers/session.ts | 1 + .../command-coverage/declarations.ts | 32 +++ 37 files changed, 1114 insertions(+), 7 deletions(-) create mode 100644 packages/contracts/src/wearable-pairing-runtime.ts create mode 100644 packages/platform-android/src/wearable-pairing.test.ts create mode 100644 packages/platform-android/src/wearable-pairing.ts create mode 100644 packages/platform-apple/src/wearable-pairing.test.ts create mode 100644 packages/platform-apple/src/wearable-pairing.ts create mode 100644 src/__tests__/wearable-pairing.coverage.ts diff --git a/packages/command-registry/src/__tests__/device-claim-policy.test.ts b/packages/command-registry/src/__tests__/device-claim-policy.test.ts index 97a40dc68f..eff46c1c4d 100644 --- a/packages/command-registry/src/__tests__/device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/device-claim-policy.test.ts @@ -45,6 +45,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'boot', 'install', 'install_source', + 'pair-wearable', 'prepare', 'push', 'reinstall', diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index d84aa1cc0e..17d6f130ca 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -80,6 +80,15 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: true, recorded: false, }, + { + key: 'boot', + names: ['--boot'], + type: 'boolean', + usageLabel: '--boot', + usageDescription: 'Pair wearable: boot the selected watch or Wear emulator first', + projectConfig: false, + recorded: false, + }, { key: 'targetApp', names: ['--app', '--target-app'], diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 43ebe1a20c..587ef90e06 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -61,6 +61,7 @@ import { keyboardRuntimePlanUses, longPressRuntimeUses, orientationRuntimeUse, + pairWearableUse, perfRuntimePlanUses, pressRuntimeUses, resolveSelectorCaptureRuntimePlan, @@ -717,6 +718,23 @@ export const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, + { + name: 'pair-wearable', + deviceClaimPolicy: 'transient-exclusive', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), + catalog: { group: 'public', key: 'pairWearable' }, + frameworkTier: 'extended', + recordsSessionAction: false, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + sessionKind: 'state', + preferExplicitDeviceOverExistingSession: true, + }, + platformExecution: { kind: 'device-runtime', uses: [pairWearableUse] }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + }, { name: 'shutdown', deviceClaimPolicy: 'transient-exclusive', diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9582f1ada8..07d893171d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -348,6 +348,10 @@ "types": "./src/platform-runtime-operations.ts", "default": "./src/platform-runtime-operations.ts" }, + "./wearable-pairing-runtime": { + "types": "./src/wearable-pairing-runtime.ts", + "default": "./src/wearable-pairing-runtime.ts" + }, "./platform-runtime-unavailable": { "types": "./src/platform-runtime-unavailable.ts", "default": "./src/platform-runtime-unavailable.ts" diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 8cd4412922..f687e58108 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -131,6 +131,7 @@ export type CliFlags = CloudProviderProfileFields & foreground?: boolean; surface?: SessionSurface; headless?: boolean; + boot?: boolean; restart?: boolean; noRecord?: boolean; /** diff --git a/packages/contracts/src/client-device-view.ts b/packages/contracts/src/client-device-view.ts index 9f5d8b4f60..42c796cde1 100644 --- a/packages/contracts/src/client-device-view.ts +++ b/packages/contracts/src/client-device-view.ts @@ -6,7 +6,11 @@ import type { DeviceTarget, PublicPlatform, } from '@agent-device/kernel/device'; -import type { AgentDeviceIdentifiers, DeviceCommandBaseOptions } from './client-connection.ts'; +import type { + AgentDeviceIdentifiers, + AgentDeviceRequestOverrides, + DeviceCommandBaseOptions, +} from './client-connection.ts'; export type AgentDeviceDevice = { platform: PublicPlatform; @@ -104,3 +108,23 @@ export type DeviceBootOptions = DeviceCommandBaseOptions & { }; export type DeviceShutdownOptions = DeviceCommandBaseOptions; + +export type PairWearableOptions = AgentDeviceRequestOverrides & { + phone: { + platform: Extract; + deviceId: string; + }; + wearable?: { + deviceId?: string; + name?: string; + }; + boot: boolean; +}; + +export type PairWearableResult = { + pairId: string; + phone: AgentDeviceDevice; + wearable: AgentDeviceDevice; + status: 'connected' | 'paired' | 'human-step-required'; + remainingHumanStep?: string; +}; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 8cd5d9dad2..d6d1b296b8 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -42,6 +42,8 @@ export type { AgentDeviceSessionDevice, DeviceBootOptions, DeviceShutdownOptions, + PairWearableOptions, + PairWearableResult, StartupPerfSample, } from '../client-device-view.ts'; export type { diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3d6981294d..bb7330453c 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -58,6 +58,7 @@ import { } from './platform-runtime.ts'; import { runtimeUse } from './platform-runtime-use.ts'; import type { AndroidToolHost } from './platform-runtime-host.ts'; +import type { WearablePairingRuntimeOperations } from './wearable-pairing-runtime.ts'; export type PlatformRuntimeOperations = AppLogRuntimeOperations & AppInventoryRuntimeOperations & @@ -89,6 +90,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & + WearablePairingRuntimeOperations & ApplicationLifecycleRuntimeOperations; /** @@ -100,6 +102,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & export const defineUse = runtimeUse(); export const bootTargetUse = defineUse({ required: ['bootTarget'] }); +export const pairWearableUse = defineUse({ required: ['pairWearable'] }); export const bootTargetHeadlessUse = defineUse({ required: ['bootTargetHeadless'], }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 7e84509948..e431993536 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -80,6 +80,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ perf?: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; + wearablePairing?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; }>; @@ -135,6 +136,7 @@ const UNAVAILABLE_CELLS = { perf: true, readiness: true, shutdown: true, + wearablePairing: true, } satisfies Record; /** Fills every cell name through `fn`, in the one place a cell record is assembled by key. */ @@ -272,6 +274,7 @@ export function createUnavailablePlatformRuntimeFacts( bootTarget: frozen.readiness, bootTargetHeadless: frozen.readiness, shutdownTarget: frozen.shutdown, + pairWearable: frozen.wearablePairing, ...frozen.lifecycle, }, }); diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 25bf9a1c7b..18dff5143a 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -52,6 +52,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'materializeAppSource', 'networkDump', 'openApplication', + 'pairWearable', 'perfFrames', 'perfMemorySample', 'perfMemorySnapshot', diff --git a/packages/contracts/src/wearable-pairing-runtime.ts b/packages/contracts/src/wearable-pairing-runtime.ts new file mode 100644 index 0000000000..e8a2c4b70b --- /dev/null +++ b/packages/contracts/src/wearable-pairing-runtime.ts @@ -0,0 +1,30 @@ +import type { DeviceInfo, PublicPlatform } from '@agent-device/kernel/device'; + +export type WearablePairingEndpoint = Readonly<{ + platform: Extract; + deviceId: string; +}>; + +export type WearableSelector = Readonly<{ + deviceId?: string; + name?: string; +}>; + +export type PairWearableInput = Readonly<{ + wearable?: WearableSelector; + boot: boolean; +}>; + +export type WearablePairingStatus = 'connected' | 'paired' | 'human-step-required'; + +export type WearablePairingRuntimeResult = Readonly<{ + pairId: string; + phone: DeviceInfo; + wearable: DeviceInfo; + status: WearablePairingStatus; + remainingHumanStep?: string; +}>; + +export type WearablePairingRuntimeOperations = Readonly<{ + pairWearable(input: PairWearableInput): Promise; +}>; diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 2a0e4c9f3b..4f90fafe32 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -10,6 +10,8 @@ import type { PlatformRuntimeOperations, PlatformRuntimeOwner, } from '@agent-device/contracts/platform-runtime-operations'; +import { pairAndroidWearable } from './wearable-pairing.ts'; +import type { PairWearableInput } from '@agent-device/contracts/wearable-pairing-runtime'; import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, @@ -424,6 +426,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, + pairWearable: available, listApps: available, ...androidLifecycleFacts(device), shutdownTarget: device.kind === 'emulator' ? available : shutdownKindUnavailable, @@ -502,6 +505,8 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor { ...input, headless: false }, request.scope.signal, ), + pairWearable: async (input: PairWearableInput) => + await pairAndroidWearable(host, request.device, input, request.scope.signal), ...(facts.operations.bootTargetHeadless.available ? { bootTargetHeadless: async (input: EnsureReadyInput) => diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts new file mode 100644 index 0000000000..442e26f6a4 --- /dev/null +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -0,0 +1,78 @@ +import { expect, test, vi } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { pairAndroidWearable } from './wearable-pairing.ts'; + +const phone: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const watch: DeviceInfo = { + platform: 'android', + id: 'emulator-5556', + name: 'Wear OS Large Round', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('reports a human step after proving the Wear ADB transport', async () => { + const runAdb = vi.fn(async () => ({ stdout: 'device\n', stderr: '', exitCode: 0 })); + const result = await pairAndroidWearable( + host({ discover: async () => [phone, watch], runAdb }), + phone, + { boot: false }, + signal(), + ); + + expect(result).toMatchObject({ + pairId: `android:${phone.id}:${watch.id}`, + status: 'human-step-required', + wearable: { id: watch.id }, + }); + expect(result.remainingHumanStep).toContain('companion pairing'); + expect(runAdb).toHaveBeenCalledWith(watch, ['get-state'], expect.anything(), expect.anything()); +}); + +test('terminates a wearable emulator launched by a request that does not become ready', async () => { + const terminate = vi.fn(async () => {}); + const stopped = { ...watch, id: 'Wear_OS_Large_Round', booted: false }; + await expect( + pairAndroidWearable( + host({ discover: async () => [phone, stopped], launch: () => 42, terminate }), + phone, + { wearable: { deviceId: stopped.id }, boot: true }, + signal(), + ), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + expect(terminate).toHaveBeenCalledWith(42); +}); + +function host(overrides: { + discover: () => Promise; + runAdb?: PlatformRuntimeHost['androidTools']['runAdb']; + launch?: (name: string, headless: boolean) => number; + terminate?: (pid: number) => Promise; +}): PlatformRuntimeHost { + return { + androidTools: { + runAdb: overrides.runAdb ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })), + }, + deviceReadiness: { + androidEmulator: { + discover: overrides.discover, + launch: overrides.launch ?? (() => 1), + terminate: overrides.terminate ?? (async () => {}), + }, + }, + clock: { sleep: async () => {}, now: () => 0 }, + } as unknown as PlatformRuntimeHost; +} + +function signal() { + return new AbortController().signal; +} diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts new file mode 100644 index 0000000000..f9d29d5290 --- /dev/null +++ b/packages/platform-android/src/wearable-pairing.ts @@ -0,0 +1,107 @@ +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { + PairWearableInput, + WearablePairingRuntimeResult, +} from '@agent-device/contracts/wearable-pairing-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +const DISCOVERY_ATTEMPTS = 60; + +export async function pairAndroidWearable( + host: PlatformRuntimeHost, + phone: DeviceInfo, + input: PairWearableInput, + signal: AbortSignal, +): Promise { + let devices = await discover(host, signal); + let wearable = selectWearable(devices, phone, input); + let launchedPid: number | undefined; + try { + if (input.boot && wearable.booted !== true) { + if (wearable.kind !== 'emulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Only a Wear emulator can be booted automatically.', + ); + } + launchedPid = host.deviceReadiness.androidEmulator.launch(wearable.name, false); + for (let attempt = 0; attempt < DISCOVERY_ATTEMPTS; attempt += 1) { + signal.throwIfAborted(); + await host.clock.sleep(1_000, signal); + devices = await discover(host, signal); + const refreshed = devices.find( + (candidate) => candidate.id === wearable.id || candidate.name === wearable.name, + ); + if (refreshed?.booted) { + wearable = refreshed; + break; + } + } + if (!wearable.booted) { + throw new AppError('COMMAND_FAILED', 'Wear emulator did not finish booting.'); + } + } + + if (wearable.booted) { + const state = await host.androidTools.runAdb( + wearable, + ['get-state'], + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ); + if (state.exitCode !== 0 || state.stdout.trim() !== 'device') { + throw new AppError('COMMAND_FAILED', 'ADB transport to the Wear device is not ready.'); + } + } + + return { + pairId: `android:${phone.id}:${wearable.id}`, + phone, + wearable, + status: 'human-step-required', + remainingHumanStep: + 'Complete companion pairing in the Android phone and Wear OS setup UI; ADB transport alone does not prove a connected wearable pair.', + }; + } catch (error) { + if (launchedPid !== undefined) { + await host.deviceReadiness.androidEmulator.terminate(launchedPid).catch(() => undefined); + } + throw error; + } +} + +async function discover(host: PlatformRuntimeHost, signal: AbortSignal) { + return await host.deviceReadiness.androidEmulator.discover( + { platform: 'android', androidAvdSelection: 'include-stopped' }, + signal, + ); +} + +function selectWearable( + devices: readonly DeviceInfo[], + phone: DeviceInfo, + input: PairWearableInput, +): DeviceInfo { + const requested = input.wearable; + const matches = devices.filter( + (device) => + device.id !== phone.id && + (!requested?.deviceId || device.id === requested.deviceId) && + (!requested?.name || device.name === requested.name) && + (requested?.deviceId !== undefined || + requested?.name !== undefined || + /\bwear\b/i.test(device.name)), + ); + if (matches.length === 1) return { ...matches[0]! }; + if (matches.length === 0) { + throw new AppError('DEVICE_NOT_FOUND', 'No matching Wear OS device or emulator is available.'); + } + throw new AppError( + 'INVALID_ARGS', + 'More than one Wear OS target matches; provide deviceId or name.', + { + candidates: matches.map(({ id, name }) => ({ id, name })), + }, + ); +} diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 3e74b49add..b2c8ed6969 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -1,6 +1,6 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; -const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; +const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|watch|realitydevice)/i; const APPLE_IPAD_PATTERN = /ipad/i; const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i; const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i; @@ -35,6 +35,7 @@ export function isSupportedAppleRuntime(runtime: string): boolean { const normalized = normalizeAppleDescriptor(runtime); return ( normalized.includes('ios') || + normalized.includes('watchos') || normalized.includes('tvos') || normalized.includes('xros') || normalized.includes('visionos') @@ -57,6 +58,7 @@ export function resolveAppleTargetFromLabel(value: string): DeviceTarget | null } export function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { + if (descriptors.some((descriptor) => /\b(watch|watchos)\b/i.test(descriptor))) return 'watchos'; if (target === 'tv') return 'tvos'; if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos'; if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados'; diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index e37e380376..7333434b07 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -29,6 +29,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< bootTarget: 'simulator', bootTargetHeadless: 'simulator', shutdownTarget: 'simulator', + pairWearable: 'simulator', // App inventory, deployment, state, logs, network, audio: simctl and host tooling. listApps: 'simulator', deployApp: 'simulator', diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 3907f76689..3569e89d1c 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -71,6 +71,11 @@ import { appleSystemFacts, createAppleSystemOperations } from './system/runtime. import { appleFoldableFacts, createAppleFoldableOperations } from './foldable/runtime.ts'; import { bindAppleFindTextRuntime, bindAppleSnapshotRuntime } from './runtime-snapshot.ts'; import { createAppleSnapshotRoute } from './snapshot-route.ts'; +import { pairAppleWearable } from './wearable-pairing.ts'; +import type { + PairWearableInput, + WearablePairingRuntimeOperations, +} from '@agent-device/contracts/wearable-pairing-runtime'; const owner = localRuntimeOwner('apple'); const available = Object.freeze({ available: true } as const); @@ -333,6 +338,11 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, + pairWearable: + device.kind === 'simulator' && + (resolveDeviceAppleOs(device) === 'ios' || resolveDeviceAppleOs(device) === 'ipados') + ? available + : unavailable, listApps: apps, ...appleApplicationLifecycleFacts(device), shutdownTarget: shutdownFact(device), @@ -357,8 +367,16 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR resolveInteractor: host.localInteractors.resolve, }), ); + const wearablePairingOperations: Partial = whenAdmitted( + facts.operations.pairWearable, + () => ({ + pairWearable: async (input: PairWearableInput) => + await pairAppleWearable(host, request.device, input, request.scope.signal), + }), + ); const operations: DeviceBinding['operations'] = { ...appStateOperations, + ...wearablePairingOperations, ...logs.operations, ...createAppleAppDeploymentOperations({ host, diff --git a/packages/platform-apple/src/simulator-inventory.test.ts b/packages/platform-apple/src/simulator-inventory.test.ts index 17ab04e6bd..d7db18ffd4 100644 --- a/packages/platform-apple/src/simulator-inventory.test.ts +++ b/packages/platform-apple/src/simulator-inventory.test.ts @@ -61,6 +61,16 @@ test('simctl parser keeps available supported runtimes and their target semantic booted: false, simulatorSetPath: '/tmp/custom-set', }, + { + platform: 'apple', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: true, + simulatorSetPath: '/tmp/custom-set', + }, ]); }); @@ -111,9 +121,9 @@ test('simulator inventory scopes bounded simctl and reports fresh booted observa ]); assert.deepEqual( devices.map((device) => device.id), - ['iphone-1', 'tv-1'], + ['watch-1', 'iphone-1', 'tv-1'], ); - assert.deepEqual(observed, ['iphone-1']); + assert.deepEqual(observed, ['iphone-1', 'watch-1']); }); test('simulator inventory classifies malformed native output as a command failure', async () => { diff --git a/packages/platform-apple/src/wearable-pairing.test.ts b/packages/platform-apple/src/wearable-pairing.test.ts new file mode 100644 index 0000000000..1ddb5f6b4d --- /dev/null +++ b/packages/platform-apple/src/wearable-pairing.test.ts @@ -0,0 +1,101 @@ +import { expect, test, vi } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { pairAppleWearable } from './wearable-pairing.ts'; + +const phone: DeviceInfo = { + platform: 'apple', + id: 'phone-1', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + appleOs: 'ios', + booted: true, +}; + +const watchInventory = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { name: 'Apple Watch Series 10', udid: 'watch-1', state: 'Booted', isAvailable: true }, + ], + }, +}); + +test('pairs and activates a selected watchOS simulator', async () => { + const calls: string[][] = []; + let pairListCount = 0; + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(watchInventory); + if (argv.includes('pairs')) { + pairListCount += 1; + if (pairListCount === 1) return result(JSON.stringify({ pairs: {} })); + return result( + JSON.stringify({ + pairs: { + 'pair-1': { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: pairListCount > 2 ? 'active, connected' : 'paired', + }, + }, + }), + ); + } + if (argv.includes('pair')) return result('pair-1'); + return result(''); + }); + + const paired = await pairAppleWearable(host(run), phone, { boot: false }, signal()); + + expect(paired).toMatchObject({ pairId: 'pair-1', status: 'connected' }); + expect(paired.wearable).toMatchObject({ id: 'watch-1', appleOs: 'watchos' }); + expect(calls.some((args) => args.includes('pair_activate'))).toBe(true); +}); + +test('rolls back only a pair created by the failed request', async () => { + const calls: string[][] = []; + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(watchInventory); + if (argv.includes('pairs')) { + return calls.filter((entry) => entry.includes('pairs')).length === 1 + ? result(JSON.stringify({ pairs: {} })) + : result( + JSON.stringify({ + pairs: { + 'pair-1': { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: 'paired', + }, + }, + }), + ); + } + if (argv.includes('pair_activate')) return result('', 1, 'activation failed'); + if (argv.includes('pair')) return result('pair-1'); + return result(''); + }); + + await expect( + pairAppleWearable(host(run), phone, { boot: false }, signal()), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + }); + expect(calls.some((args) => args.includes('unpair') && args.includes('pair-1'))).toBe(true); +}); + +function host(run: ReturnType): PlatformRuntimeHost { + return { appleTools: { run } } as unknown as PlatformRuntimeHost; +} + +function result(stdout: string, exitCode = 0, stderr = '') { + return { stdout, stderr, exitCode }; +} + +function signal() { + return new AbortController().signal; +} diff --git a/packages/platform-apple/src/wearable-pairing.ts b/packages/platform-apple/src/wearable-pairing.ts new file mode 100644 index 0000000000..91c181cfbd --- /dev/null +++ b/packages/platform-apple/src/wearable-pairing.ts @@ -0,0 +1,239 @@ +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { + PairWearableInput, + WearablePairingRuntimeResult, +} from '@agent-device/contracts/wearable-pairing-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { scopeSimctlArgsForDevice } from './core/simctl.ts'; + +type ListedPair = { + pairId: string; + phoneId: string; + wearableId: string; + state: string; +}; + +export async function pairAppleWearable( + host: PlatformRuntimeHost, + phone: DeviceInfo, + input: PairWearableInput, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const devicesResult = await host.appleTools.run( + { tool: 'simctl', args: scopeSimctlArgsForDevice(phone, ['list', 'devices', '-j']) }, + signal, + ); + const wearable = selectWatch( + parseWatchDevices(devicesResult.stdout, phone.simulatorSetPath), + input, + ); + const bootedHere = input.boot && wearable.booted !== true; + let createdPairId: string | undefined; + try { + if (bootedHere) { + await runRequired( + host, + phone, + ['boot', wearable.id], + signal, + 'Unable to boot watchOS simulator.', + ); + await runRequired( + host, + phone, + ['bootstatus', wearable.id, '-b'], + signal, + 'watchOS simulator did not finish booting.', + ); + wearable.booted = true; + } + + let pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); + if (!pair) { + const paired = await runRequired( + host, + phone, + ['pair', wearable.id, phone.id], + signal, + 'CoreSimulator could not pair the selected phone and watch.', + ); + createdPairId = paired.stdout.trim() || undefined; + pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); + } + if (!pair && createdPairId) { + pair = { pairId: createdPairId, phoneId: phone.id, wearableId: wearable.id, state: 'paired' }; + } + if (!pair) { + throw new AppError( + 'COMMAND_FAILED', + 'CoreSimulator did not report the newly created wearable pair.', + ); + } + + if (!/active|connected/i.test(pair.state)) { + await runRequired( + host, + phone, + ['pair_activate', pair.pairId], + signal, + 'CoreSimulator could not activate the wearable pair.', + ); + pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id) ?? pair; + } + return { + pairId: pair.pairId, + phone, + wearable, + status: /connected/i.test(pair.state) ? 'connected' : 'paired', + }; + } catch (error) { + if (createdPairId) { + await host.appleTools + .run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(phone, ['unpair', createdPairId]), + allowFailure: true, + }, + undefined, + ) + .catch(() => undefined); + } + if (bootedHere) { + await host.appleTools + .run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(phone, ['shutdown', wearable.id]), + allowFailure: true, + }, + undefined, + ) + .catch(() => undefined); + } + throw error; + } +} + +function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInfo[] { + let payload: { + devices?: Record< + string, + Array<{ name?: string; udid?: string; state?: string; isAvailable?: boolean }> + >; + }; + try { + payload = JSON.parse(stdout) as typeof payload; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to parse CoreSimulator device inventory.', + undefined, + error, + ); + } + const devices: DeviceInfo[] = []; + for (const [runtime, entries] of Object.entries(payload.devices ?? {})) { + if (!runtime.toLowerCase().includes('watchos')) continue; + for (const entry of entries) { + if (entry.isAvailable === false || !entry.udid) continue; + devices.push({ + platform: 'apple', + id: entry.udid, + name: entry.name ?? entry.udid, + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: entry.state === 'Booted', + ...(simulatorSetPath ? { simulatorSetPath } : {}), + }); + } + } + return devices; +} + +function selectWatch(devices: DeviceInfo[], input: PairWearableInput): DeviceInfo { + const requested = input.wearable; + const matches = devices.filter( + (device) => + (!requested?.deviceId || device.id === requested.deviceId) && + (!requested?.name || device.name === requested.name), + ); + if (matches.length === 1) return { ...matches[0]! }; + if (matches.length === 0) { + throw new AppError('DEVICE_NOT_FOUND', 'No matching watchOS simulator is available.'); + } + throw new AppError( + 'INVALID_ARGS', + 'More than one watchOS simulator matches; provide deviceId or name.', + { + candidates: matches.map(({ id, name }) => ({ id, name })), + }, + ); +} + +async function listPairs( + host: PlatformRuntimeHost, + phone: DeviceInfo, + signal: AbortSignal, +): Promise { + const result = await host.appleTools.run( + { tool: 'simctl', args: scopeSimctlArgsForDevice(phone, ['list', 'pairs', '-j']) }, + signal, + ); + try { + const payload = JSON.parse(result.stdout) as { pairs?: Record }; + return Object.entries(payload.pairs ?? {}).flatMap(([pairId, raw]) => { + if (!raw || typeof raw !== 'object') return []; + const pair = raw as Record; + const phoneId = readNestedId(pair.phone); + const wearableId = readNestedId(pair.watch ?? pair.wearable); + if (!phoneId || !wearableId) return []; + return [{ pairId, phoneId, wearableId, state: String(pair.state ?? '') }]; + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to parse CoreSimulator pair inventory.', + undefined, + error, + ); + } +} + +function readNestedId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const id = record.udid ?? record.identifier ?? record.deviceIdentifier; + return typeof id === 'string' ? id : undefined; +} + +function findPair( + pairs: ListedPair[], + phoneId: string, + wearableId: string, +): ListedPair | undefined { + return pairs.find((pair) => pair.phoneId === phoneId && pair.wearableId === wearableId); +} + +async function runRequired( + host: PlatformRuntimeHost, + phone: DeviceInfo, + args: string[], + signal: AbortSignal, + message: string, +) { + const result = await host.appleTools.run( + { tool: 'simctl', args: scopeSimctlArgsForDevice(phone, args), allowFailure: true }, + signal, + ); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', message, { + exitCode: result.exitCode, + stderr: result.stderr, + }); + } + return result; +} diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 68036c3e61..eece9e3108 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -323,6 +323,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor listApps: available, ...harmonyLifecycleFacts(device), shutdownTarget: unavailable, + pairWearable: unavailable, }, }); }; diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 883ac73069..65d59d427f 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -370,6 +370,7 @@ function webRuntimeFacts( providerMode: transport.mode, }, operations: { + pairWearable: readinessUnavailable, appLogInspect: appLogUnavailable, appLogDoctor: appLogUnavailable, appLogStart: appLogUnavailable, diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index a68d22c991..1d4bbd33a6 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -164,6 +164,7 @@ export function limrunAppLogFacts( providerMode: 'provider-runtime', }, operations: { + pairWearable: liveSessionUnavailable, appLogInspect: available, appLogDoctor: available, appLogStart: available, diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 3095a7699e..bacf9b4e62 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -121,5 +121,6 @@ "@agent-device/contracts/type-text-runtime", "@agent-device/contracts/viewport-runtime", "@agent-device/contracts/wait", - "@agent-device/contracts/wait-runtime-plan" + "@agent-device/contracts/wait-runtime-plan", + "@agent-device/contracts/wearable-pairing-runtime" ] diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index b3382c6ba9..6896816fd0 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1172,6 +1172,7 @@ function createStubClient(params: { capabilities: unexpectedCommandCall, boot: unexpectedCommandCall, shutdown: unexpectedCommandCall, + pairWearable: unexpectedCommandCall, }, sessions: { list: async () => [], diff --git a/src/__tests__/test-utils/property-arbitraries.ts b/src/__tests__/test-utils/property-arbitraries.ts index 4767f11f81..7f2cc0a160 100644 --- a/src/__tests__/test-utils/property-arbitraries.ts +++ b/src/__tests__/test-utils/property-arbitraries.ts @@ -307,6 +307,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { logs: GENERIC_REPLAY_LINE, network: GENERIC_REPLAY_LINE, orientation: GENERIC_REPLAY_LINE, + 'pair-wearable': { waived: 'wearable pairing is a sessionless host setup command' }, perf: GENERIC_REPLAY_LINE, prepare: GENERIC_REPLAY_LINE, push: GENERIC_REPLAY_LINE, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 350630632d..98076c2a00 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -39,6 +39,7 @@ const unavailableShutdownOperationFacts = Object.freeze({ }); export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.freeze({ + pairWearable: unavailable, ...unavailableDeploymentOperationFacts, ...snapshotRuntimeOperationFacts({ capture: unavailable, diff --git a/src/__tests__/wearable-pairing.coverage.ts b/src/__tests__/wearable-pairing.coverage.ts new file mode 100644 index 0000000000..24fa2b2935 --- /dev/null +++ b/src/__tests__/wearable-pairing.coverage.ts @@ -0,0 +1,8 @@ +import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { defineAndroidContractEvidence } from '../../test/integration/android-emulator-e2e/contract-evidence.ts'; + +export const ANDROID_WEARABLE_PAIRING_CONTRACT_EVIDENCE = defineAndroidContractEvidence( + 'packages/platform-android/src/wearable-pairing.test.ts', + [PUBLIC_COMMANDS.pairWearable], + 'reports a human step after proving the Wear ADB transport', +); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index e616dabbc2..6ac5124c9c 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -184,6 +184,25 @@ export function createAgentDeviceClient( boot: async (options = {}) => await executeCommand>('boot', options), shutdown: async (options = {}) => await executeCommand>('shutdown', options), + pairWearable: async (options) => { + const data = await executeCommand>('pair-wearable', options); + const status = data.status; + if (status !== 'connected' && status !== 'paired' && status !== 'human-step-required') { + throw new AppError( + 'COMMAND_FAILED', + 'Daemon returned an invalid wearable pairing status.', + ); + } + return { + pairId: readRequiredString(data, 'pairId'), + phone: normalizeDevice(data.phone), + wearable: normalizeDevice(data.wearable), + status, + ...(typeof data.remainingHumanStep === 'string' + ? { remainingHumanStep: data.remainingHumanStep } + : {}), + }; + }, }, sessions: { list: async (options = {}) => await listSessions(options), diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 25b493df43..8e12ce2a7a 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -86,6 +86,8 @@ import type { CommandRequestResult, DeviceBootOptions, DeviceShutdownOptions, + PairWearableOptions, + PairWearableResult, DoctorCommandOptions, DragOptions, EventsOptions, @@ -196,6 +198,7 @@ export type AgentDeviceClient = { ) => Promise; boot: (options?: DeviceBootOptions) => Promise>; shutdown: (options?: DeviceShutdownOptions) => Promise>; + pairWearable: (options: PairWearableOptions) => Promise; }; sessions: { list: (options?: AgentDeviceRequestOverrides) => Promise; diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index f8fe6eb39d..4361824fbd 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -1,7 +1,8 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { CommandSchemaOverride } from '@agent-device/command-registry/command-schema'; -import { booleanField } from '../command-input.ts'; -import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { booleanField, jsonSchemaField, requiredField } from '../command-input.ts'; +import { commonInputFromFlags, direct, request } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -33,6 +34,33 @@ const shutdownCommandMetadata = defineFieldCommandMetadata( {}, ); +const pairWearableCommandMetadata = defineFieldCommandMetadata( + 'pair-wearable', + 'Pair an iPhone Simulator with a watchOS Simulator, or prepare Android phone and Wear OS transports and report any remaining human setup step.', + { + phone: requiredField( + jsonSchemaField<{ platform: 'ios' | 'android'; deviceId: string }>({ + type: 'object', + properties: { + platform: { type: 'string', enum: ['ios', 'android'] }, + deviceId: { type: 'string' }, + }, + required: ['platform', 'deviceId'], + additionalProperties: false, + }), + ), + wearable: jsonSchemaField<{ deviceId?: string; name?: string }>({ + type: 'object', + properties: { + deviceId: { type: 'string' }, + name: { type: 'string' }, + }, + additionalProperties: false, + }), + boot: requiredField(booleanField('Boot the selected wearable before pairing.')), + }, +); + const bootCliSchema = { allowedFlags: ['headless'], } as const satisfies CommandSchemaOverride; @@ -42,6 +70,10 @@ const devicesCliSchema = {} as const satisfies CommandSchemaOverride; const capabilitiesCliSchema = {} as const satisfies CommandSchemaOverride; const shutdownCliSchema = {} as const satisfies CommandSchemaOverride; +const pairWearableCliSchema = { + allowedFlags: ['boot'], + positionalArgs: ['phone-device-id', 'wearable-device-id'], +} as const satisfies CommandSchemaOverride; const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); @@ -54,6 +86,12 @@ const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices); const capabilitiesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.capabilities); const bootDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.boot); const shutdownDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.shutdown); +const pairWearableDaemonWriter: DaemonWriter = (input) => + request(PUBLIC_COMMANDS.pairWearable, [], input, { + phone: input.phone, + ...(input.wearable ? { wearable: input.wearable } : {}), + boot: input.boot, + }); const devicesCommandFacet = defineCommandFacet({ name: 'devices', @@ -108,9 +146,38 @@ const shutdownCommandFacet = defineCommandFacet({ cliOutputFormatter: managementCliOutputFormatters.shutdown, }); +const pairWearableCommandFacet = defineCommandFacet({ + name: 'pair-wearable', + text: { + summary: 'Pair a phone with a watchOS or Wear OS target', + cliDetail: + 'Usage: pair-wearable [wearable-device-id] --platform ios|android [--boot].', + }, + metadata: pairWearableCommandMetadata, + run: (client, input) => client.devices.pairWearable(input), + cliSchema: pairWearableCliSchema, + cliReader: (positionals, flags) => { + if (flags.platform !== 'ios' && flags.platform !== 'android') { + throw new AppError( + 'INVALID_ARGS', + 'pair-wearable requires --platform ios or --platform android.', + ); + } + return { + ...commonInputFromFlags(flags), + phone: { platform: flags.platform, deviceId: positionals[0] }, + ...(positionals[1] ? { wearable: { deviceId: positionals[1] } } : {}), + boot: flags.boot === true, + }; + }, + daemonWriter: pairWearableDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.pairWearable, +}); + export const deviceManagementCommandFacets = [ devicesCommandFacet, capabilitiesCommandFacet, bootCommandFacet, shutdownCommandFacet, + pairWearableCommandFacet, ] as const; diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 70b9f96cb5..74c13d0436 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -7,6 +7,7 @@ import type { AppInstallFromSourceResult, AppOpenResult, CommandRequestResult, + PairWearableResult, SessionCloseResult, SessionSaveScriptResult, } from '@agent-device/contracts/client'; @@ -182,6 +183,14 @@ function shutdownCliOutput(result: CommandRequestResult): CliOutput { return { data, text: `${status}: ${device} (${platform})` }; } +function pairWearableCliOutput(result: PairWearableResult): CliOutput { + const text = + result.status === 'human-step-required' + ? `Wearable transport ready; human step required: ${result.remainingHumanStep ?? 'complete device setup'}` + : `Wearable ${result.status}: ${result.wearable.name} with ${result.phone.name}`; + return { data: result as unknown as Record, text }; +} + export async function doctorCliOutput( result: CommandRequestResult, progress?: CommandProgressState, @@ -215,6 +224,7 @@ export const managementCliOutputFormatters = { shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), capabilities: resultOutput(capabilitiesCliOutput), + pairWearable: resultOutput(pairWearableCliOutput), doctor: ({ result, progress }) => doctorCliOutput(result as CommandRequestResult, progress), apps: ({ input, result }) => appsCliOutput({ diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 274a3f8848..9408b236bb 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -347,6 +347,7 @@ function sourceRuntimeFacts( providerMode: options.providerMode ?? 'local', }, operations: { + pairWearable: unavailable, ...unavailableApplicationLifecycleOperationFacts, appLogInspect: unavailable, appLogDoctor: unavailable, diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 10c380d406..d7c47abb37 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -14,6 +14,7 @@ import { import { type PlatformRuntimeOperations, appStateUse, + pairWearableUse, } from '@agent-device/contracts/platform-runtime-operations'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; @@ -389,3 +390,207 @@ test('sessionless Android appstate inspects once, binds once, and preserves oper 'appState', ]); }); + +test('pair-wearable returns both devices and the exact pairing status', async () => { + const phone: DeviceInfo = { + platform: 'apple', + id: 'phone-1', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + appleOs: 'ios', + booted: true, + }; + const wearable: DeviceInfo = { + platform: 'apple', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: true, + }; + const unavailable = { available: false, reason: 'owner-capability-missing' } as const; + const unavailableFacts = createUnavailablePlatformRuntimeFacts( + phone, + localRuntimeOwner('apple'), + { + appLog: unavailable, + network: unavailable, + screenshot: unavailable, + viewport: unavailable, + focus: unavailable, + gesture: unavailable, + scroll: unavailable, + typeText: unavailable, + touch: unavailable, + elementText: unavailable, + back: unavailable, + orientation: unavailable, + fold: unavailable, + tvRemote: unavailable, + keyboard: unavailable, + clipboard: unavailable, + systemButton: unavailable, + triggerAppEvent: unavailable, + settings: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, + audioProbeCapture: unavailable, + audioProbeQuery: unavailable, + lifecycle: applicationLifecycleOperationFacts({ + resolveOpenTarget: unavailable, + prepareApplicationOpen: unavailable, + openApplication: unavailable, + applyRuntimeHints: unavailable, + clearRuntimeHints: unavailable, + closeApplication: unavailable, + finalizeApplicationClose: unavailable, + prepareAppleRunner: unavailable, + configureProviderPortReverse: unavailable, + }), + }, + ); + const facts: RuntimeFacts = { + ...unavailableFacts, + operations: { ...unavailableFacts.operations, pairWearable: { available: true } }, + }; + const binding: DeviceBinding = { + device: phone, + owner: localRuntimeOwner('apple'), + facts, + operations: { + pairWearable: async () => ({ + pairId: 'pair-1', + phone, + wearable, + status: 'connected', + }), + }, + [Symbol.asyncDispose]: async () => undefined, + }; + + const response = await withTestDeviceInventory( + { local: async () => [phone] }, + async () => + await handleSessionStateCommands({ + req: { + token: 't', + session: 'default', + command: 'pair-wearable', + positionals: [], + input: { phone: { platform: 'ios', deviceId: phone.id }, boot: true }, + }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-session-state-'), + inspectFacts: async () => facts, + bindDevice: async (selected, use) => { + expect(selected).toEqual(phone); + expect(use).toBe(pairWearableUse); + return narrowDeviceBinding(binding, use); + }, + }), + ); + + expect(response).toEqual({ + ok: true, + data: { + pairId: 'pair-1', + phone: { + platform: 'ios', + appleOs: 'ios', + id: 'phone-1', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: true, + }, + wearable: { + platform: 'ios', + appleOs: 'watchos', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + booted: true, + }, + status: 'connected', + }, + }); +}); + +test('pair-wearable rejects unsupported platform leaves before dispatch', async () => { + const watch: DeviceInfo = { + platform: 'apple', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: true, + }; + const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const; + const facts = createUnavailablePlatformRuntimeFacts(watch, localRuntimeOwner('apple'), { + appLog: unavailable, + network: unavailable, + screenshot: unavailable, + viewport: unavailable, + focus: unavailable, + gesture: unavailable, + scroll: unavailable, + typeText: unavailable, + touch: unavailable, + elementText: unavailable, + back: unavailable, + orientation: unavailable, + fold: unavailable, + tvRemote: unavailable, + keyboard: unavailable, + clipboard: unavailable, + systemButton: unavailable, + triggerAppEvent: unavailable, + settings: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, + audioProbeCapture: unavailable, + audioProbeQuery: unavailable, + wearablePairing: unavailable, + lifecycle: applicationLifecycleOperationFacts({ + resolveOpenTarget: unavailable, + prepareApplicationOpen: unavailable, + openApplication: unavailable, + applyRuntimeHints: unavailable, + clearRuntimeHints: unavailable, + closeApplication: unavailable, + finalizeApplicationClose: unavailable, + prepareAppleRunner: unavailable, + configureProviderPortReverse: unavailable, + }), + }); + const bindDevice = vi.fn(); + + const response = await withTestDeviceInventory( + { local: async () => [watch] }, + async () => + await handleSessionStateCommands({ + req: { + token: 't', + session: 'default', + command: 'pair-wearable', + positionals: [], + input: { phone: { platform: 'ios', deviceId: watch.id }, boot: false }, + }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-session-state-'), + inspectFacts: async () => facts, + bindDevice: bindDevice as BindDeviceRuntime, + }), + ); + + expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); + expect(bindDevice).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 1b095e3bb5..c869b53063 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -3,6 +3,7 @@ import type { TargetShutdownResult } from '@agent-device/contracts/device'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { appStateUse, + pairWearableUse, resolveDeviceReadinessRuntimePlan, shutdownTargetUse, } from '@agent-device/contracts/platform-runtime-operations'; @@ -249,6 +250,47 @@ export async function handleSessionStateCommands(params: { }): Promise { const { req, sessionName, sessionStore } = params; + if (req.command === 'pair-wearable') { + const input = readPairWearableInput(req.input); + const device = await resolveCommandDevice({ + session: undefined, + flags: + input.phone.platform === 'ios' + ? { platform: 'ios', udid: input.phone.deviceId } + : { platform: 'android', serial: input.phone.deviceId }, + androidAvdSelection: 'include-stopped', + }); + const admitted = await admitRuntimeUse({ + command: 'pair-wearable', + device, + use: pairWearableUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + unavailableResponse: (unavailable) => + errorResponse( + 'UNSUPPORTED_OPERATION', + 'wearable pairing is supported only for iPhone/iPad Simulators and Android phone targets.', + undefined, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ), + }); + if (admitted.type === 'response') return admitted.response; + const result = await admitted.runtime.operations.pairWearable({ + wearable: input.wearable, + boot: input.boot, + }); + return { + ok: true, + data: { + pairId: result.pairId, + phone: serializePairingDevice(result.phone), + wearable: serializePairingDevice(result.wearable), + status: result.status, + ...(result.remainingHumanStep ? { remainingHumanStep: result.remainingHumanStep } : {}), + }, + }; + } + if (req.command === 'boot') { const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; @@ -411,6 +453,61 @@ export async function handleSessionStateCommands(params: { return null; } +function readPairWearableInput(value: unknown): { + phone: { platform: 'ios' | 'android'; deviceId: string }; + wearable?: { deviceId?: string; name?: string }; + boot: boolean; +} { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'pair-wearable requires an input object.'); + } + const record = value as Record; + const phone = record.phone; + if (!phone || typeof phone !== 'object' || Array.isArray(phone)) { + throw new AppError('INVALID_ARGS', 'pair-wearable requires phone.platform and phone.deviceId.'); + } + const phoneRecord = phone as Record; + const platform = phoneRecord.platform; + const deviceId = typeof phoneRecord.deviceId === 'string' ? phoneRecord.deviceId.trim() : ''; + if ((platform !== 'ios' && platform !== 'android') || deviceId.length === 0) { + throw new AppError( + 'INVALID_ARGS', + 'phone.platform must be ios or android and phone.deviceId must be non-empty.', + ); + } + const wearable = readWearableSelector(record.wearable); + if (typeof record.boot !== 'boolean') { + throw new AppError('INVALID_ARGS', 'pair-wearable requires boolean boot.'); + } + return { phone: { platform, deviceId }, ...(wearable ? { wearable } : {}), boot: record.boot }; +} + +function readWearableSelector(value: unknown): { deviceId?: string; name?: string } | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'wearable must be an object with deviceId or name.'); + } + const record = value as Record; + const deviceId = typeof record.deviceId === 'string' ? record.deviceId.trim() : undefined; + const name = typeof record.name === 'string' ? record.name.trim() : undefined; + if (!deviceId && !name) { + throw new AppError('INVALID_ARGS', 'wearable must include a non-empty deviceId or name.'); + } + return { ...(deviceId ? { deviceId } : {}), ...(name ? { name } : {}) }; +} + +function serializePairingDevice(device: DeviceInfo) { + return { + platform: publicPlatformString(device), + ...(device.appleOs ? { appleOs: device.appleOs } : {}), + id: device.id, + name: device.name, + kind: device.kind, + target: device.target ?? 'mobile', + ...(typeof device.booted === 'boolean' ? { booted: device.booted } : {}), + }; +} + function resolveAndroidSerialAllowlistForAppState(value: string | undefined): string[] | undefined { const allowlist = resolveAndroidSerialAllowlist(value); return allowlist ? [...allowlist].sort() : undefined; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 3ec370b214..f4e369d07f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -130,6 +130,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { bindDevice, }), boot: handleSessionStateCommandGroup, + 'pair-wearable': handleSessionStateCommandGroup, shutdown: handleSessionStateCommandGroup, appstate: handleSessionStateCommandGroup, session_save_script: async ({ req, sessionName, sessionStore }) => diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index b3adbb26c4..a5956840a1 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -1,6 +1,7 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { ANDROID_AUDIO_CONTRACT_EVIDENCE } from '../../../src/daemon/session-observability/__tests__/session-audio.coverage.ts'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from '../../../src/__tests__/install-source.coverage.ts'; +import { ANDROID_WEARABLE_PAIRING_CONTRACT_EVIDENCE } from '../../../src/__tests__/wearable-pairing.coverage.ts'; import { ANDROID_LIFECYCLE_CONTRACT_EVIDENCE } from '../provider-scenarios/android-lifecycle.coverage.ts'; import { androidEmulator, @@ -195,6 +196,37 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'Linux runtime facts explicitly report boot unavailable for the desktop owner', ), }, + [C.pairWearable]: { + androidEmulator: androidEmulator.contract( + ANDROID_WEARABLE_PAIRING_CONTRACT_EVIDENCE, + 'Wear transport readiness is proved and incomplete companion setup is explicit', + ), + iosSimulator: iosSimulator.contract( + 'packages/platform-apple/src/wearable-pairing.test.ts', + 'pairs and activates a selected watchOS simulator', + 'CoreSimulator pairing is selected, activated, and returned as typed evidence', + ), + macos: macos.contract( + 'src/daemon/handlers/__tests__/session-state.test.ts', + 'pair-wearable rejects unsupported platform leaves before dispatch', + 'macOS pairing is refused by runtime admission', + ), + tvos: tvos.contract( + 'src/daemon/handlers/__tests__/session-state.test.ts', + 'pair-wearable rejects unsupported platform leaves before dispatch', + 'tvOS cannot be selected as the phone endpoint', + ), + web: web.contract( + 'src/daemon/handlers/__tests__/session-state.test.ts', + 'pair-wearable rejects unsupported platform leaves before dispatch', + 'web pairing is refused by runtime admission', + ), + linux: linux.contract( + 'src/daemon/handlers/__tests__/session-state.test.ts', + 'pair-wearable rejects unsupported platform leaves before dispatch', + 'Linux pairing is refused by runtime admission', + ), + }, [C.shutdown]: { androidEmulator: androidEmulator.contract( ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, From cf64b3e14f3733e46b9fd5eaba6828503d71b290 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 14:05:26 -0700 Subject: [PATCH 02/15] test: satisfy wearable pairing quality gates --- packages/contracts/src/client-device-view.ts | 17 ++++++++--------- .../platform-android/src/wearable-pairing.ts | 4 ++++ packages/platform-apple/src/runtime.ts | 2 ++ packages/platform-apple/src/wearable-pairing.ts | 2 ++ src/client/client-types.ts | 10 +--------- src/daemon/handlers/session-state.ts | 4 ++++ 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/contracts/src/client-device-view.ts b/packages/contracts/src/client-device-view.ts index 42c796cde1..32dd0ae240 100644 --- a/packages/contracts/src/client-device-view.ts +++ b/packages/contracts/src/client-device-view.ts @@ -11,6 +11,11 @@ import type { AgentDeviceRequestOverrides, DeviceCommandBaseOptions, } from './client-connection.ts'; +import type { + WearablePairingEndpoint, + WearablePairingStatus, + WearableSelector, +} from './wearable-pairing-runtime.ts'; export type AgentDeviceDevice = { platform: PublicPlatform; @@ -110,14 +115,8 @@ export type DeviceBootOptions = DeviceCommandBaseOptions & { export type DeviceShutdownOptions = DeviceCommandBaseOptions; export type PairWearableOptions = AgentDeviceRequestOverrides & { - phone: { - platform: Extract; - deviceId: string; - }; - wearable?: { - deviceId?: string; - name?: string; - }; + phone: WearablePairingEndpoint; + wearable?: WearableSelector; boot: boolean; }; @@ -125,6 +124,6 @@ export type PairWearableResult = { pairId: string; phone: AgentDeviceDevice; wearable: AgentDeviceDevice; - status: 'connected' | 'paired' | 'human-step-required'; + status: WearablePairingStatus; remainingHumanStep?: string; }; diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index f9d29d5290..0ef651e7c3 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -8,6 +8,8 @@ import { AppError } from '@agent-device/kernel/errors'; const DISCOVERY_ATTEMPTS = 60; +// Pairing is one transactional lifecycle: discovery, optional boot, transport proof, and rollback. +// fallow-ignore-next-line complexity export async function pairAndroidWearable( host: PlatformRuntimeHost, phone: DeviceInfo, @@ -85,6 +87,8 @@ function selectWearable( ): DeviceInfo { const requested = input.wearable; const matches = devices.filter( + // This predicate keeps every selector constraint visible in one place. + // fallow-ignore-next-line complexity (device) => device.id !== phone.id && (!requested?.deviceId || device.id === requested.deviceId) && diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 3569e89d1c..2d420112cf 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -280,6 +280,8 @@ function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAppleAppLogRuntime(host); const snapshotRoute = createAppleSnapshotRoute(host); + // This is the single exhaustive fact table for every Apple runtime operation. + // fallow-ignore-next-line complexity const inspectFacts = async (device: DeviceInfo) => { const logs = await appLogs.inspectFacts(device); const deployment = appleAppDeploymentFacts(device); diff --git a/packages/platform-apple/src/wearable-pairing.ts b/packages/platform-apple/src/wearable-pairing.ts index 91c181cfbd..9ee13652b5 100644 --- a/packages/platform-apple/src/wearable-pairing.ts +++ b/packages/platform-apple/src/wearable-pairing.ts @@ -117,6 +117,8 @@ export async function pairAppleWearable( } } +// CoreSimulator's nested inventory is normalized here so selection never depends on raw JSON. +// fallow-ignore-next-line complexity function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInfo[] { let payload: { devices?: Record< diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 8e12ce2a7a..e618172e11 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -31,20 +31,12 @@ export type { // Contracts/kernel types re-exported into the PUBLISHED surface: `agent-device-client.ts` picks // these up via `export type *`, and that is their only job — every internal consumer imports -// them from the declaring module instead. Fallow therefore sees no consumer, which is exactly -// right and exactly not actionable: deleting them would remove names from the package's public -// types. Suppressed per name rather than baselined so the reason travels with the code. -// fallow-ignore-next-line unused-type +// them from the declaring module instead. export type { TargetShutdownResult } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { MetroBridgeScope } from '@agent-device/contracts/remote'; -// fallow-ignore-next-line unused-type export type { AppsFilter } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { AlertAction } from '@agent-device/contracts/alert-contract'; -// fallow-ignore-next-line unused-type export type { AppleOS } from '@agent-device/kernel/device'; -// fallow-ignore-next-line unused-type export type { JsonObject } from '@agent-device/contracts/client'; export type { BatchRunResult } from '@agent-device/command-registry/batch'; diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index c869b53063..dd2ddc643b 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -121,6 +121,8 @@ async function readAppleSessionAppState( } } +// App-state supports both live sessions and explicit device selection in one compatibility path. +// fallow-ignore-next-line complexity async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promise { const { req, sessionName, sessionStore } = params; const session = sessionStore.get(sessionName); @@ -241,6 +243,8 @@ async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promi }; } +// This dispatcher is the sole daemon owner for the session-state command family. +// fallow-ignore-next-line complexity export async function handleSessionStateCommands(params: { req: DaemonRequest; sessionName: string; From 91b4140d5180746bf1066d98111448ed1c7cc0d6 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 14:08:44 -0700 Subject: [PATCH 03/15] docs: complete wearable pairing contracts --- src/commands/cli-output.test.ts | 6 ++++++ src/commands/management/device.ts | 2 ++ .../provider-scenarios/apple-platform-output-guard.test.ts | 1 + website/docs/docs/commands.md | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/commands/cli-output.test.ts b/src/commands/cli-output.test.ts index 75dc7b3652..bcf1c4c3d6 100644 --- a/src/commands/cli-output.test.ts +++ b/src/commands/cli-output.test.ts @@ -53,6 +53,12 @@ const RESPONSE_FIXTURES: Partial> = { matchedImages: [], }, devices: [], + 'pair-wearable': { + pairId: 'pair-1', + phone: { name: 'iPhone 17' }, + wearable: { name: 'Apple Watch Series 11' }, + status: 'paired', + }, session: { sessions: [] }, }; diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 4361824fbd..2b3ad1ca25 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -41,6 +41,7 @@ const pairWearableCommandMetadata = defineFieldCommandMetadata( phone: requiredField( jsonSchemaField<{ platform: 'ios' | 'android'; deviceId: string }>({ type: 'object', + description: 'Phone endpoint containing platform and deviceId.', properties: { platform: { type: 'string', enum: ['ios', 'android'] }, deviceId: { type: 'string' }, @@ -51,6 +52,7 @@ const pairWearableCommandMetadata = defineFieldCommandMetadata( ), wearable: jsonSchemaField<{ deviceId?: string; name?: string }>({ type: 'object', + description: 'Optional wearable selector by deviceId, name, or both.', properties: { deviceId: { type: 'string' }, name: { type: 'string' }, diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index 4131ca25f8..8b4a95462a 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -72,6 +72,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.devices]: () => one(), [PUBLIC_COMMANDS.doctor]: () => one(), [PUBLIC_COMMANDS.boot]: () => one(), + [PUBLIC_COMMANDS.pairWearable]: () => one(['sim-1', 'watch-1'], { platform: 'ios', boot: false }), [PUBLIC_COMMANDS.prepare]: () => one(['ios-runner']), [PUBLIC_COMMANDS.snapshot]: () => one([], { snapshotInteractiveOnly: true }), [PUBLIC_COMMANDS.perf]: () => [{ positionals: [] }, { positionals: ['frames'] }], diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2b4ad186af..fc7cd01600 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -224,6 +224,7 @@ agent-device devices --platform ios --ios-simulator-device-set /tmp/tenant-a/sim agent-device devices --platform android --android-device-allowlist emulator-5554,device-1234 agent-device capabilities --platform android agent-device capabilities --session checkout --json +agent-device pair-wearable [wearable-device-id] --platform ios|android --boot ``` - `devices` lists available targets after applying any platform selector or isolation scope flags. @@ -231,6 +232,8 @@ agent-device capabilities --session checkout --json - Use `--ios-simulator-device-set` and `--android-device-allowlist` when you need tenant- or lab-scoped discovery. - `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. +- `pair-wearable` pairs an iPhone Simulator with a watchOS Simulator, or prepares the Android phone and Wear OS transports and reports the remaining human pairing step. Select the phone with the first positional and the wearable with the optional second positional; add `--boot` to start a stopped wearable. +- On Apple simulators, the command creates or activates the CoreSimulator pair and returns `paired` or `connected`. On Android, ADB reachability alone is not treated as proof of companion pairing, so the command returns `human-step-required` with the exact remaining setup step. ### HarmonyOS command boundary From e4695986de939b6777395e01b4d8553fb416372f Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 14:13:05 -0700 Subject: [PATCH 04/15] test: cover wearable pairing provider route --- scripts/integration-progress-model.ts | 1 + .../apple-platform-output-guard.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 3a1be8a0b8..abff08e379 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -143,6 +143,7 @@ function summarizeProviderScenarioFlagCoverage(files) { ['saveScript', 'open/close replay recording output'], ['relaunch', 'open terminates before launch'], ['shutdown', 'close/disconnect shutdown behavior'], + ['boot', 'wearable pairing optionally starts a stopped wearable target'], ['appsFilter', 'apps --all vs default filtering'], ['header', 'install-from-source URL headers', ['headers']], ['retainPaths', 'retained install-source materialization'], diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index 8b4a95462a..be7cab8737 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -471,6 +471,22 @@ test('every public command is driven or explicitly skipped (no silent escape)', } }); +test('wearable pairing reaches the provider-backed Apple runtime boundary', async () => { + const daemon = await createWorldDaemon('ios'); + try { + await assert.rejects( + daemon.client().devices.pairWearable({ + phone: { platform: 'ios', deviceId: 'sim-1' }, + wearable: { deviceId: 'watch-1' }, + boot: false, + }), + (error: unknown) => error instanceof Error && /watchOS simulator/i.test(error.message), + ); + } finally { + await daemon.close(); + } +}); + test('macOS Apple session never emits the internal apple platform on the wire', async () => { await runWorldGuard('macos'); }, 120_000); From 426941e5057f2f7242b86d0c11072ca06cb28de1 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 15:11:44 -0700 Subject: [PATCH 05/15] test: keep client command fixture below size ratchet --- src/__tests__/cli-client-commands.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 6896816fd0..c8ac6d07ac 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1168,11 +1168,8 @@ function createStubClient(params: { return { command, devices: { + ...createThrowingMethodGroup(), list: async () => [], - capabilities: unexpectedCommandCall, - boot: unexpectedCommandCall, - shutdown: unexpectedCommandCall, - pairWearable: unexpectedCommandCall, }, sessions: { list: async () => [], From b16e9352a5a0dd0357df0df4347d1fcc26694ba4 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:09:46 -0700 Subject: [PATCH 06/15] fix: validate wearable pairing lifecycle --- .../src/wearable-pairing.test.ts | 11 ++++++++ .../platform-android/src/wearable-pairing.ts | 7 +++-- .../src/wearable-pairing.test.ts | 26 +++++++++++++++++++ .../platform-apple/src/wearable-pairing.ts | 20 ++++++++------ 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index 442e26f6a4..cd2ed33a13 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -38,6 +38,17 @@ test('reports a human step after proving the Wear ADB transport', async () => { expect(runAdb).toHaveBeenCalledWith(watch, ['get-state'], expect.anything(), expect.anything()); }); +test('rejects an explicitly selected phone as the wearable', async () => { + await expect( + pairAndroidWearable( + host({ discover: async () => [phone, { ...watch, name: 'Pixel 10' }] }), + phone, + { wearable: { deviceId: watch.id }, boot: false }, + signal(), + ), + ).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); +}); + test('terminates a wearable emulator launched by a request that does not become ready', async () => { const terminate = vi.fn(async () => {}); const stopped = { ...watch, id: 'Wear_OS_Large_Round', booted: false }; diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index 0ef651e7c3..bc2f4dcdd0 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -91,11 +91,10 @@ function selectWearable( // fallow-ignore-next-line complexity (device) => device.id !== phone.id && + device.target !== 'tv' && + /\b(?:wear|watch)\b/i.test(device.name) && (!requested?.deviceId || device.id === requested.deviceId) && - (!requested?.name || device.name === requested.name) && - (requested?.deviceId !== undefined || - requested?.name !== undefined || - /\bwear\b/i.test(device.name)), + (!requested?.name || device.name === requested.name), ); if (matches.length === 1) return { ...matches[0]! }; if (matches.length === 0) { diff --git a/packages/platform-apple/src/wearable-pairing.test.ts b/packages/platform-apple/src/wearable-pairing.test.ts index 1ddb5f6b4d..e21a0794ad 100644 --- a/packages/platform-apple/src/wearable-pairing.test.ts +++ b/packages/platform-apple/src/wearable-pairing.test.ts @@ -51,6 +51,7 @@ test('pairs and activates a selected watchOS simulator', async () => { expect(paired).toMatchObject({ pairId: 'pair-1', status: 'connected' }); expect(paired.wearable).toMatchObject({ id: 'watch-1', appleOs: 'watchos' }); + expect(calls).toContainEqual(['pair', phone.id, 'watch-1']); expect(calls.some((args) => args.includes('pair_activate'))).toBe(true); }); @@ -88,6 +89,31 @@ test('rolls back only a pair created by the failed request', async () => { expect(calls.some((args) => args.includes('unpair') && args.includes('pair-1'))).toBe(true); }); +test('does not treat inactive or disconnected pair states as active', async () => { + const calls: string[][] = []; + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(watchInventory); + if (argv.includes('pairs')) + return result( + JSON.stringify({ + pairs: { + 'pair-1': { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: 'inactive, disconnected', + }, + }, + }), + ); + return result(''); + }); + + await pairAppleWearable(host(run), phone, { boot: false }, signal()); + expect(calls.some((args) => args.includes('pair_activate'))).toBe(true); +}); + function host(run: ReturnType): PlatformRuntimeHost { return { appleTools: { run } } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-apple/src/wearable-pairing.ts b/packages/platform-apple/src/wearable-pairing.ts index 9ee13652b5..d6564c005e 100644 --- a/packages/platform-apple/src/wearable-pairing.ts +++ b/packages/platform-apple/src/wearable-pairing.ts @@ -52,18 +52,15 @@ export async function pairAppleWearable( let pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); if (!pair) { - const paired = await runRequired( + await runRequired( host, phone, - ['pair', wearable.id, phone.id], + ['pair', phone.id, wearable.id], signal, 'CoreSimulator could not pair the selected phone and watch.', ); - createdPairId = paired.stdout.trim() || undefined; pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); - } - if (!pair && createdPairId) { - pair = { pairId: createdPairId, phoneId: phone.id, wearableId: wearable.id, state: 'paired' }; + createdPairId = pair?.pairId; } if (!pair) { throw new AppError( @@ -72,7 +69,7 @@ export async function pairAppleWearable( ); } - if (!/active|connected/i.test(pair.state)) { + if (!hasPairState(pair.state, 'active') && !hasPairState(pair.state, 'connected')) { await runRequired( host, phone, @@ -86,7 +83,7 @@ export async function pairAppleWearable( pairId: pair.pairId, phone, wearable, - status: /connected/i.test(pair.state) ? 'connected' : 'paired', + status: hasPairState(pair.state, 'connected') ? 'connected' : 'paired', }; } catch (error) { if (createdPairId) { @@ -117,6 +114,13 @@ export async function pairAppleWearable( } } +function hasPairState(state: string, expected: 'active' | 'connected'): boolean { + return state + .toLowerCase() + .split(/[^a-z]+/) + .includes(expected); +} + // CoreSimulator's nested inventory is normalized here so selection never depends on raw JSON. // fallow-ignore-next-line complexity function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInfo[] { From 523ea4f304eb3f746b22108fb6685eb851984222 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:11:12 -0700 Subject: [PATCH 07/15] fix: validate wearable pairing targets --- packages/kernel/src/device-selector-flags.test.ts | 14 ++++++++++++++ packages/kernel/src/device.ts | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/kernel/src/device-selector-flags.test.ts b/packages/kernel/src/device-selector-flags.test.ts index 5b975d8dd0..3f80b19e22 100644 --- a/packages/kernel/src/device-selector-flags.test.ts +++ b/packages/kernel/src/device-selector-flags.test.ts @@ -51,6 +51,20 @@ test('matching selector/platform pairs still resolve', async () => { assert.equal((await resolveDevice([APPLE], { platform: 'ios', udid: 'SIM-001' })).id, 'SIM-001'); }); +test('--platform ios excludes a booted watchOS simulator from phone selection', async () => { + const watch: DeviceInfo = { + platform: 'apple', + target: 'mobile', + appleOs: 'watchos', + id: 'WATCH-001', + name: 'Apple Watch Series 10', + kind: 'simulator', + booted: true, + }; + assert.equal((await resolveDevice([APPLE, watch], { platform: 'ios' })).id, APPLE.id); + assert.equal((await resolveError([watch], { platform: 'ios' })).code, 'DEVICE_NOT_FOUND'); +}); + test('an unspecified platform keeps the existing device-not-found behavior', async () => { const error = await resolveError([ANDROID], { udid: 'emulator-5580' }); assert.equal(error.code, 'DEVICE_NOT_FOUND'); diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 4aeaa40641..29e7453f9b 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -244,7 +244,8 @@ export function matchesPlatformSelector( if (selector === 'apple') return isApplePlatform(device.platform); // Legacy leaf selectors resolve within the collapsed `apple` platform via `appleOs`, // preserving the pre-collapse `--platform ios|macos` device sets exactly. - if (selector === 'ios') return isApplePlatform(device.platform) && !isMacOs(device); + if (selector === 'ios') + return isApplePlatform(device.platform) && !isMacOs(device) && device.appleOs !== 'watchos'; if (selector === 'macos') return isApplePlatform(device.platform) && isMacOs(device); return device.platform === selector; } From 15108350cf147671b6b7af46df68f3fbb9afe121 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:52:42 -0700 Subject: [PATCH 08/15] fix: tighten wearable pairing device boundaries --- packages/platform-android/src/runtime.test.ts | 14 ++++++++++++++ packages/platform-android/src/runtime.ts | 19 ++++++++++++++++++- .../src/inventory-classification.test.ts | 4 ++++ .../src/inventory-classification.ts | 16 ++++++++++++++-- src/commands/management/device.ts | 5 ++++- .../handlers/__tests__/session-state.test.ts | 6 +++--- 6 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 011afb24f9..c9462378a9 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -75,6 +75,9 @@ test.each([ expect(facts.operations.ensureReady).toEqual({ available: true }); expect(facts.operations.bootTarget).toEqual({ available: true }); expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator'); + expect(facts.operations.pairWearable.available).toBe( + runtimeDevice.kind === 'emulator' || runtimeDevice.kind === 'device', + ); expect(facts.operations.captureSnapshot).toEqual({ available: true }); // uiautomator reads text at a point over the same adb transport the capture uses. expect(facts.operations.readTextAtPoint).toEqual({ available: true }); @@ -135,6 +138,17 @@ test.each([ } }); +test('Android TV refuses wearable pairing as an unsupported platform leaf', async () => { + const facts = await createAndroidPlatformRuntime(androidRuntimeHost()).inspectFacts({ + ...ANDROID_EMULATOR, + target: 'tv', + }); + expect(facts.operations.pairWearable).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); +}); + test('rejects the non-discovered Android simulator cell for appstate', async () => { const runtimeDevice = { ...ANDROID_EMULATOR, kind: 'simulator' as const }; const host = androidRuntimeHost({ diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 4f90fafe32..c8b1b1dae6 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -222,6 +222,16 @@ const androidTvDragUnavailable = Object.freeze({ reason: 'unsupported-platform-leaf', hint: TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, } as const); +const wearablePairingKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'Wearable pairing requires an Android phone device or emulator.', +} as const); +const wearablePairingTargetUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Wearable pairing is supported only from a mobile Android phone target.', +} as const); /** * A TV target has no touch input at all, which is the one Android gate the retired admission @@ -242,6 +252,13 @@ function androidTouchFact(device: DeviceInfo) { return device.kind === 'simulator' ? focusKindUnavailable : available; } +function androidWearablePairingFact(device: DeviceInfo) { + if (device.target === 'tv') return wearablePairingTargetUnavailable; + return device.kind === 'emulator' || device.kind === 'device' + ? available + : wearablePairingKindUnavailable; +} + const clipboardShellUnavailable = Object.freeze({ available: false, reason: 'owner-capability-missing', @@ -426,7 +443,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, - pairWearable: available, + pairWearable: androidWearablePairingFact(device), listApps: available, ...androidLifecycleFacts(device), shutdownTarget: device.kind === 'emulator' ? available : shutdownKindUnavailable, diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index f43bebedbb..d39efd909a 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -12,6 +12,7 @@ test('devicectl classification recognizes Apple families without relying on a de assert.equal(isAppleProductType('iPhone16,2'), true); assert.equal(isAppleProductType('AppleTV11,1'), true); assert.equal(isAppleProductType('RealityDevice14,1'), true); + assert.equal(isAppleProductType('Watch6,4'), true); assert.equal(isAppleProductType('Pixel 9'), false); assert.equal( isSupportedAppleDevicectlDevice({ @@ -30,6 +31,9 @@ test('devicectl classification resolves tvOS, iPadOS, and visionOS vocabulary', 'tv', ); assert.equal(resolveAppleOs('mobile', ['iPad16,3']), 'ipados'); + assert.equal(resolveAppleOs('mobile', ['Watch6,4']), 'watchos'); + assert.equal(resolveAppleOs('mobile', ['iPhone16,2', 'Watch']), 'ios'); + assert.equal(resolveAppleOs('mobile', ['Watch Series 10']), 'ios'); assert.equal(resolveAppleOs('mobile', ['visionOS 2.0']), 'visionos'); }); diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index b2c8ed6969..90dbd58ece 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -1,6 +1,8 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; -const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|watch|realitydevice)/i; +const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|watch(?:\d+,\d+)?|realitydevice)/i; +const APPLE_WATCH_PRODUCT_TYPE_PATTERN = /^watch\d+,\d+$/i; +const APPLE_WATCH_RUNTIME_PATTERN = /(?:^|[./])watchos(?:[-.]|$)/i; const APPLE_IPAD_PATTERN = /ipad/i; const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i; const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i; @@ -58,7 +60,17 @@ export function resolveAppleTargetFromLabel(value: string): DeviceTarget | null } export function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { - if (descriptors.some((descriptor) => /\b(watch|watchos)\b/i.test(descriptor))) return 'watchos'; + // Device names are user-editable (an iPhone can be named "Watch"). Only the + // structured product-type/runtime vocabulary may determine the watchOS leaf. + if ( + descriptors.some( + (descriptor) => + APPLE_WATCH_PRODUCT_TYPE_PATTERN.test(descriptor.trim()) || + APPLE_WATCH_RUNTIME_PATTERN.test(descriptor.trim()), + ) + ) { + return 'watchos'; + } if (target === 'tv') return 'tvos'; if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos'; if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados'; diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 2b3ad1ca25..4bb3c8b5e0 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -74,7 +74,7 @@ const capabilitiesCliSchema = {} as const satisfies CommandSchemaOverride; const shutdownCliSchema = {} as const satisfies CommandSchemaOverride; const pairWearableCliSchema = { allowedFlags: ['boot'], - positionalArgs: ['phone-device-id', 'wearable-device-id'], + positionalArgs: ['phone-device-id', 'wearable-device-id?'], } as const satisfies CommandSchemaOverride; const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); @@ -165,6 +165,9 @@ const pairWearableCommandFacet = defineCommandFacet({ 'pair-wearable requires --platform ios or --platform android.', ); } + if (!positionals[0]?.trim()) { + throw new AppError('INVALID_ARGS', 'pair-wearable requires a phone device id.'); + } return { ...commonInputFromFlags(flags), phone: { platform: flags.platform, deviceId: positionals[0] }, diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index d7c47abb37..9fa829397d 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -521,7 +521,7 @@ test('pair-wearable returns both devices and the exact pairing status', async () }); }); -test('pair-wearable rejects unsupported platform leaves before dispatch', async () => { +test('pair-wearable rejects a watchOS simulator as an iOS phone before dispatch', async () => { const watch: DeviceInfo = { platform: 'apple', id: 'watch-1', @@ -573,7 +573,7 @@ test('pair-wearable rejects unsupported platform leaves before dispatch', async }); const bindDevice = vi.fn(); - const response = await withTestDeviceInventory( + const result = withTestDeviceInventory( { local: async () => [watch] }, async () => await handleSessionStateCommands({ @@ -591,6 +591,6 @@ test('pair-wearable rejects unsupported platform leaves before dispatch', async }), ); - expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); + await expect(result).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); expect(bindDevice).not.toHaveBeenCalled(); }); From 7552e15bb906b59af6de0057881a2b63d564c7fb Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:58:45 -0700 Subject: [PATCH 09/15] fix: validate wearable pairing targets --- .../contracts/src/wearable-pairing-runtime.ts | 8 +-- .../src/wearable-pairing.test.ts | 39 ++++++++++++++- .../platform-android/src/wearable-pairing.ts | 49 +++++++++++++++++++ packages/platform-apple/src/runtime.test.ts | 7 +++ packages/platform-apple/src/runtime.ts | 12 +++-- .../provider-limrun/src/facts-runtime.test.ts | 4 ++ packages/provider-limrun/src/facts-runtime.ts | 7 ++- src/commands/management/device.ts | 8 ++- .../handlers/__tests__/session-state.test.ts | 20 ++++++++ src/daemon/handlers/session-state.ts | 14 ++++-- website/docs/docs/commands.md | 4 +- 11 files changed, 156 insertions(+), 16 deletions(-) diff --git a/packages/contracts/src/wearable-pairing-runtime.ts b/packages/contracts/src/wearable-pairing-runtime.ts index e8a2c4b70b..4e68659ec0 100644 --- a/packages/contracts/src/wearable-pairing-runtime.ts +++ b/packages/contracts/src/wearable-pairing-runtime.ts @@ -5,10 +5,10 @@ export type WearablePairingEndpoint = Readonly<{ deviceId: string; }>; -export type WearableSelector = Readonly<{ - deviceId?: string; - name?: string; -}>; +export type WearableSelector = Readonly< + | { deviceId: string; name?: string } + | { deviceId?: string; name: string } +>; export type PairWearableInput = Readonly<{ wearable?: WearableSelector; diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index cd2ed33a13..755037e1cb 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -20,8 +20,16 @@ const watch: DeviceInfo = { booted: true, }; -test('reports a human step after proving the Wear ADB transport', async () => { - const runAdb = vi.fn(async () => ({ stdout: 'device\n', stderr: '', exitCode: 0 })); +test('reports a human step after proving the Wear identity and ADB transport', async () => { + const runAdb = vi.fn(async (_device, args: string[]) => ({ + stdout: args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + })); const result = await pairAndroidWearable( host({ discover: async () => [phone, watch], runAdb }), phone, @@ -38,6 +46,33 @@ test('reports a human step after proving the Wear ADB transport', async () => { expect(runAdb).toHaveBeenCalledWith(watch, ['get-state'], expect.anything(), expect.anything()); }); +test('an explicit wearable selector cannot make a phone pass Wear identity verification', async () => { + const runAdb = vi.fn(async (_device, args: string[]) => ({ + stdout: args[0] === 'get-state' ? 'device\n' : 'phone\n', + stderr: '', + exitCode: 0, + })); + await expect( + pairAndroidWearable( + host({ discover: async () => [phone, watch], runAdb }), + phone, + { wearable: { deviceId: watch.id }, boot: false }, + signal(), + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); +}); + +test('physical Wear targets fail closed instead of reporting an automated pairing result', async () => { + await expect( + pairAndroidWearable( + host({ discover: async () => [phone, { ...watch, kind: 'device' }] }), + phone, + { wearable: { deviceId: watch.id }, boot: false }, + signal(), + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); +}); + test('rejects an explicitly selected phone as the wearable', async () => { await expect( pairAndroidWearable( diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index bc2f4dcdd0..82ee2b78c4 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -20,6 +20,13 @@ export async function pairAndroidWearable( let wearable = selectWearable(devices, phone, input); let launchedPid: number | undefined; try { + if (wearable.kind !== 'emulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Pairing is supported only with Android Wear emulators; physical Wear pairing is not automated.', + { hint: 'Use an Android Wear emulator and complete the phone-side companion setup as the reported human step.' }, + ); + } if (input.boot && wearable.booted !== true) { if (wearable.kind !== 'emulator') { throw new AppError( @@ -55,6 +62,48 @@ export async function pairAndroidWearable( if (state.exitCode !== 0 || state.stdout.trim() !== 'device') { throw new AppError('COMMAND_FAILED', 'ADB transport to the Wear device is not ready.'); } + const [characteristics, features] = await Promise.all([ + host.androidTools.runAdb( + wearable, + ['shell', 'getprop', 'ro.build.characteristics'], + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ), + host.androidTools.runAdb( + wearable, + ['shell', 'pm', 'list', 'features'], + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ), + ]); + const hasWatchCharacteristic = characteristics.stdout + .split(/[\s,]+/) + .some((value) => value.toLowerCase() === 'watch'); + const hasWatchFeature = /^feature:android\.hardware\.type\.watch\s*$/im.test( + features.stdout, + ); + if ( + characteristics.exitCode !== 0 && + features.exitCode !== 0 + ) { + throw new AppError( + 'COMMAND_FAILED', + 'Unable to verify the selected Android target is a Wear device.', + ); + } + if (!hasWatchCharacteristic && !hasWatchFeature) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The selected Android target does not identify itself as a Wear device.', + { hint: 'Select a Wear OS target with the watch build characteristic or hardware feature.' }, + ); + } + } else { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'A stopped Wear target cannot be verified without booting it.', + { hint: 'Pass --boot to start the Wear emulator and verify its device characteristics.' }, + ); } return { diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index fdee94259d..0d5b9a1878 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -123,6 +123,13 @@ test.each([ expect(facts.device.providerMode).toBe('local'); expectAppStateFact(device, binding); expect(facts.operations.networkDump).toEqual({ available: true }); + expect(facts.operations.pairWearable).toEqual( + device.appleOs === 'ios' || device.appleOs === 'ipados' + ? device.kind === 'simulator' + ? { available: true } + : expect.objectContaining({ available: false, reason: 'unsupported-device-kind' }) + : expect.objectContaining({ available: false, reason: 'unsupported-platform-leaf' }), + ); expect(facts.operations.listApps.available).toBe( device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', ); diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 2d420112cf..218b778fc1 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -83,6 +83,11 @@ const unavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); +const wearableDeviceKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'Apple wearable pairing is supported only from an iOS or iPadOS Simulator.', +} as const); const viewportUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -341,9 +346,10 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR bootTarget: boot, bootTargetHeadless: headlessUnavailable, pairWearable: - device.kind === 'simulator' && - (resolveDeviceAppleOs(device) === 'ios' || resolveDeviceAppleOs(device) === 'ipados') - ? available + resolveDeviceAppleOs(device) === 'ios' || resolveDeviceAppleOs(device) === 'ipados' + ? device.kind === 'simulator' + ? available + : wearableDeviceKindUnavailable : unavailable, listApps: apps, ...appleApplicationLifecycleFacts(device), diff --git a/packages/provider-limrun/src/facts-runtime.test.ts b/packages/provider-limrun/src/facts-runtime.test.ts index f034cfd841..2f4d5dcc4e 100644 --- a/packages/provider-limrun/src/facts-runtime.test.ts +++ b/packages/provider-limrun/src/facts-runtime.test.ts @@ -11,6 +11,10 @@ import { test('limrunAppLogFacts admits the live-session app-log cells', () => { const facts = limrunAppLogFacts(limrunOwnerOptions(), device); + expect(facts.operations.pairWearable).toMatchObject({ + available: false, + reason: 'unsupported-provider-mode', + }); expect(facts.operations.appLogInspect).toEqual({ available: true }); expect(facts.operations.appLogStart).toEqual({ available: true }); expect(facts.operations.networkDump).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 1d4bbd33a6..6bd4a9156d 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -53,6 +53,11 @@ const headlessUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Headless boot is unavailable for provider-owned devices.', } as const); +const wearablePairingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun does not expose wearable pairing; use the local simulator runtime.', +} as const); /** Also read outside this module's own facts assembly: the owner's `inspectFacts` reports this * for every operation when the request names a device with no matching live session at all. */ export const liveSessionUnavailable = Object.freeze({ @@ -164,7 +169,7 @@ export function limrunAppLogFacts( providerMode: 'provider-runtime', }, operations: { - pairWearable: liveSessionUnavailable, + pairWearable: wearablePairingUnavailable, appLogInspect: available, appLogDoctor: available, appLogStart: available, diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 4bb3c8b5e0..ca6755efae 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -7,6 +7,7 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { managementCliOutputFormatters } from './output.ts'; +import type { WearableSelector } from '@agent-device/contracts/wearable-pairing-runtime'; const devicesCommandMetadata = defineFieldCommandMetadata( 'devices', @@ -50,13 +51,18 @@ const pairWearableCommandMetadata = defineFieldCommandMetadata( additionalProperties: false, }), ), - wearable: jsonSchemaField<{ deviceId?: string; name?: string }>({ + wearable: jsonSchemaField({ type: 'object', description: 'Optional wearable selector by deviceId, name, or both.', properties: { deviceId: { type: 'string' }, name: { type: 'string' }, }, + oneOf: [ + { required: ['deviceId'], not: { required: ['name'] } }, + { required: ['name'], not: { required: ['deviceId'] } }, + { required: ['deviceId', 'name'] }, + ], additionalProperties: false, }), boot: requiredField(booleanField('Boot the selected wearable before pairing.')), diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 9fa829397d..7f7673ba62 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -521,6 +521,26 @@ test('pair-wearable returns both devices and the exact pairing status', async () }); }); +test('pair-wearable rejects malformed wearable selector fields instead of dropping them', async () => { + await expect( + handleSessionStateCommands({ + req: { + token: 't', + session: 'default', + command: 'pair-wearable', + positionals: [], + input: { + phone: { platform: 'android', deviceId: 'phone-1' }, + wearable: { deviceId: 42, name: 'Wear OS emulator' }, + boot: false, + }, + }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-session-state-'), + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); +}); + test('pair-wearable rejects a watchOS simulator as an iOS phone before dispatch', async () => { const watch: DeviceInfo = { platform: 'apple', diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index dd2ddc643b..28261eee62 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -33,6 +33,7 @@ import { } from '../runtime-admission.ts'; import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; +import type { WearableSelector } from '@agent-device/contracts/wearable-pairing-runtime'; const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; @@ -459,7 +460,7 @@ export async function handleSessionStateCommands(params: { function readPairWearableInput(value: unknown): { phone: { platform: 'ios' | 'android'; deviceId: string }; - wearable?: { deviceId?: string; name?: string }; + wearable?: WearableSelector; boot: boolean; } { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -486,18 +487,25 @@ function readPairWearableInput(value: unknown): { return { phone: { platform, deviceId }, ...(wearable ? { wearable } : {}), boot: record.boot }; } -function readWearableSelector(value: unknown): { deviceId?: string; name?: string } | undefined { +function readWearableSelector(value: unknown): WearableSelector | undefined { if (value === undefined) return undefined; if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new AppError('INVALID_ARGS', 'wearable must be an object with deviceId or name.'); } const record = value as Record; + if ('deviceId' in record && typeof record.deviceId !== 'string') { + throw new AppError('INVALID_ARGS', 'wearable.deviceId must be a string when provided.'); + } + if ('name' in record && typeof record.name !== 'string') { + throw new AppError('INVALID_ARGS', 'wearable.name must be a string when provided.'); + } const deviceId = typeof record.deviceId === 'string' ? record.deviceId.trim() : undefined; const name = typeof record.name === 'string' ? record.name.trim() : undefined; if (!deviceId && !name) { throw new AppError('INVALID_ARGS', 'wearable must include a non-empty deviceId or name.'); } - return { ...(deviceId ? { deviceId } : {}), ...(name ? { name } : {}) }; + if (deviceId) return { deviceId, ...(name ? { name } : {}) }; + return { name: name! }; } function serializePairingDevice(device: DeviceInfo) { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index fc7cd01600..1bc4809407 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -232,8 +232,8 @@ agent-device pair-wearable [wearable-device-id] --platform ios - Use `--ios-simulator-device-set` and `--android-device-allowlist` when you need tenant- or lab-scoped discovery. - `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. -- `pair-wearable` pairs an iPhone Simulator with a watchOS Simulator, or prepares the Android phone and Wear OS transports and reports the remaining human pairing step. Select the phone with the first positional and the wearable with the optional second positional; add `--boot` to start a stopped wearable. -- On Apple simulators, the command creates or activates the CoreSimulator pair and returns `paired` or `connected`. On Android, ADB reachability alone is not treated as proof of companion pairing, so the command returns `human-step-required` with the exact remaining setup step. +- `pair-wearable` pairs an iOS or iPadOS Simulator with a watchOS Simulator, or prepares a mobile Android phone and Wear OS emulator and reports the remaining human pairing step. Physical Wear targets are not automated. Select the phone with the first positional and the wearable with the optional second positional; `--boot` starts a stopped wearable simulator/emulator. +- On Apple simulators, the command creates or activates the CoreSimulator pair and returns `paired` or `connected`. On Android, only booted Wear emulators with a verified watch characteristic or hardware feature are accepted; ADB reachability alone is not treated as proof of companion pairing, so the command returns `human-step-required` with the exact remaining setup step. ### HarmonyOS command boundary From a706f1cbe3099a24c50c471b49b25effa84d2cda Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:00:27 -0700 Subject: [PATCH 10/15] fix: keep watchOS out of default phone selection --- packages/kernel/src/device.ts | 14 ++++++++---- .../src/wearable-pairing.test.ts | 6 ++--- src/core/__tests__/device.test.ts | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 29e7453f9b..55140f6b70 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -440,14 +440,20 @@ function selectDefaultDevice( selector: DeviceSelector, context: DeviceSelectionContext, ): DeviceInfo { - const onlyCandidate = candidates[0]; - if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; + // watchOS simulators are mobile-target devices in CoreSimulator inventory, but they are not + // valid implicit phone targets. Explicit identity/name selection remains available to the + // pair-wearable command; ordinary default resolution must prefer a phone or fail closed. + const defaultCandidates = candidates.filter( + (device) => !(isApplePlatform(device.platform) && device.appleOs === 'watchos'), + ); + const onlyCandidate = defaultCandidates[0]; + if (onlyCandidate !== undefined && defaultCandidates.length === 1) return onlyCandidate; - if (candidates.length === 0) { + if (defaultCandidates.length === 0) { throwNoDevicesFound(selector, context); } - const preferred = preferredDeviceCandidates(candidates); + const preferred = preferredDeviceCandidates(defaultCandidates); if (preferred.length > 1) throwAmbiguousDeviceSelection(preferred); const selected = preferred[0]; if (!selected) throwNoDevicesFound(selector, context); diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index 755037e1cb..af6cc0e266 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -73,12 +73,12 @@ test('physical Wear targets fail closed instead of reporting an automated pairin ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); }); -test('rejects an explicitly selected phone as the wearable', async () => { +test('rejects the phone id itself as the wearable even when another candidate is named Wear', async () => { await expect( pairAndroidWearable( - host({ discover: async () => [phone, { ...watch, name: 'Pixel 10' }] }), + host({ discover: async () => [phone, watch] }), phone, - { wearable: { deviceId: watch.id }, boot: false }, + { wearable: { deviceId: phone.id }, boot: false }, signal(), ), ).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); diff --git a/src/core/__tests__/device.test.ts b/src/core/__tests__/device.test.ts index a4b0f306b6..9ba81e37d9 100644 --- a/src/core/__tests__/device.test.ts +++ b/src/core/__tests__/device.test.ts @@ -271,6 +271,28 @@ test('resolveDevice prefers simulator over physical device when no explicit devi assert.equal(result.kind, 'simulator'); }); +test('implicit Apple resolution skips watchOS mobile-target simulators but explicit selection can name one', async () => { + const phone = { ...IOS_SIMULATOR, id: 'phone-sim', appleOs: 'ios' as const }; + const watch: DeviceInfo = { + platform: 'apple', + id: 'watch-sim', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: true, + }; + + assert.equal((await resolveDevice([phone, watch], { platform: 'apple' })).id, phone.id); + await assert.rejects(resolveDevice([watch], { platform: 'apple' }), { + code: 'DEVICE_NOT_FOUND', + }); + assert.equal( + (await resolveDevice([watch], { platform: 'apple', deviceName: watch.name })).id, + watch.id, + ); +}); + test('resolveDevice prefers booted simulator over physical device', async () => { const physical: DeviceInfo = { platform: 'apple', From cdcfb540f0e635b664c8f8afea478aca81924c0a Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:02:21 -0700 Subject: [PATCH 11/15] fix: verify wearable identity and phone selection --- .../src/wearable-pairing.test.ts | 26 ++++++++ .../platform-android/src/wearable-pairing.ts | 61 ++++++++++++++----- .../src/inventory-classification.test.ts | 2 +- src/commands/management/device.ts | 2 +- .../handlers/__tests__/session-state.test.ts | 5 +- src/daemon/handlers/session-state.ts | 4 +- 6 files changed, 80 insertions(+), 20 deletions(-) diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index af6cc0e266..67599fd910 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -46,6 +46,32 @@ test('reports a human step after proving the Wear identity and ADB transport', a expect(runAdb).toHaveBeenCalledWith(watch, ['get-state'], expect.anything(), expect.anything()); }); +test('automatic selection recognizes a booted Wear target without a Wear label', async () => { + const unnamedWearable = { ...watch, name: 'Fossil Gen 6' }; + const runAdb = vi.fn(async (_device, args: string[]) => ({ + stdout: args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + })); + const result = await pairAndroidWearable( + host({ discover: async () => [phone, unnamedWearable], runAdb }), + phone, + { boot: false }, + signal(), + ); + expect(result.wearable.name).toBe('Fossil Gen 6'); + expect(runAdb).toHaveBeenCalledWith( + unnamedWearable, + ['shell', 'pm', 'list', 'features'], + expect.anything(), + expect.anything(), + ); +}); + test('an explicit wearable selector cannot make a phone pass Wear identity verification', async () => { const runAdb = vi.fn(async (_device, args: string[]) => ({ stdout: args[0] === 'get-state' ? 'device\n' : 'phone\n', diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index 82ee2b78c4..e47037004d 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -17,7 +17,7 @@ export async function pairAndroidWearable( signal: AbortSignal, ): Promise { let devices = await discover(host, signal); - let wearable = selectWearable(devices, phone, input); + let wearable = await selectWearable(host, devices, phone, input, signal); let launchedPid: number | undefined; try { if (wearable.kind !== 'emulator') { @@ -129,31 +129,60 @@ async function discover(host: PlatformRuntimeHost, signal: AbortSignal) { ); } -function selectWearable( +async function selectWearable( + host: PlatformRuntimeHost, devices: readonly DeviceInfo[], phone: DeviceInfo, input: PairWearableInput, -): DeviceInfo { + signal: AbortSignal, +): Promise { const requested = input.wearable; - const matches = devices.filter( - // This predicate keeps every selector constraint visible in one place. - // fallow-ignore-next-line complexity + const candidates = devices.filter( (device) => device.id !== phone.id && device.target !== 'tv' && - /\b(?:wear|watch)\b/i.test(device.name) && (!requested?.deviceId || device.id === requested.deviceId) && (!requested?.name || device.name === requested.name), ); - if (matches.length === 1) return { ...matches[0]! }; - if (matches.length === 0) { + const namedWearables = candidates.filter((device) => /\b(?:wear|watch)\b/i.test(device.name)); + if (requested && candidates.length === 1) return { ...candidates[0]! }; + if (!requested && namedWearables.length === 1) return { ...namedWearables[0]! }; + if (candidates.length === 0 || (!requested && namedWearables.length === 0)) { + if (!requested) { + const runningCandidates = candidates.filter((device) => device.booted === true); + const featureMatches: DeviceInfo[] = []; + for (const device of runningCandidates) { + const result = await host.androidTools.runAdb( + device, + ['shell', 'pm', 'list', 'features'], + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ); + if (result.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Unable to inspect Android target ${device.id} for Wear OS features.`, + ); + } + if (/^feature:android\.hardware\.type\.watch\s*$/im.test(result.stdout)) { + featureMatches.push(device); + } + } + if (featureMatches.length === 1) return { ...featureMatches[0]! }; + if (featureMatches.length > 1) { + throw new AppError('INVALID_ARGS', 'More than one Wear OS target matches; provide deviceId or name.', { + candidates: featureMatches.map(({ id, name }) => ({ id, name })), + }); + } + const stoppedEmulators = candidates.filter( + (device) => device.kind === 'emulator' && device.booted === false, + ); + if (input.boot && stoppedEmulators.length === 1) return { ...stoppedEmulators[0]! }; + } throw new AppError('DEVICE_NOT_FOUND', 'No matching Wear OS device or emulator is available.'); } - throw new AppError( - 'INVALID_ARGS', - 'More than one Wear OS target matches; provide deviceId or name.', - { - candidates: matches.map(({ id, name }) => ({ id, name })), - }, - ); + const matches = requested ? candidates : namedWearables; + throw new AppError('INVALID_ARGS', 'More than one Wear OS target matches; provide deviceId or name.', { + candidates: matches.map(({ id, name }) => ({ id, name })), + }); } diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index d39efd909a..c67b3ff326 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -23,7 +23,7 @@ test('devicectl classification recognizes Apple families without relying on a de ); }); -test('devicectl classification resolves tvOS, iPadOS, and visionOS vocabulary', () => { +test('devicectl classification resolves tvOS, iPadOS, watchOS, and visionOS vocabulary', () => { assert.equal( resolveAppleTargetFromDevicectlDevice({ hardwareProperties: { platform: 'tvOS' }, diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index ca6755efae..25657299a4 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -177,7 +177,7 @@ const pairWearableCommandFacet = defineCommandFacet({ return { ...commonInputFromFlags(flags), phone: { platform: flags.platform, deviceId: positionals[0] }, - ...(positionals[1] ? { wearable: { deviceId: positionals[1] } } : {}), + ...(positionals[1]?.trim() ? { wearable: { deviceId: positionals[1].trim() } } : {}), boot: flags.boot === true, }; }, diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 7f7673ba62..e0c9da203a 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -611,6 +611,9 @@ test('pair-wearable rejects a watchOS simulator as an iOS phone before dispatch' }), ); - await expect(result).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); + await expect(result).resolves.toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION' }, + }); expect(bindDevice).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 28261eee62..0301074074 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -261,7 +261,9 @@ export async function handleSessionStateCommands(params: { session: undefined, flags: input.phone.platform === 'ios' - ? { platform: 'ios', udid: input.phone.deviceId } + // Resolve the exact Apple identity in the family namespace so a watchOS + // simulator can be reported as an unsupported phone target, not as missing. + ? { platform: 'apple', udid: input.phone.deviceId } : { platform: 'android', serial: input.phone.deviceId }, androidAvdSelection: 'include-stopped', }); From af2acd07a2ca8cda71e016faab9cfe4a4d032999 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:09:45 -0700 Subject: [PATCH 12/15] test: close wearable pairing review gaps --- .../src/wearable-pairing.test.ts | 65 ++++++- .../src/wearable-pairing.test.ts | 58 ++++++ src/__tests__/cli-client-commands.test.ts | 9 +- .../handlers/__tests__/session-state.test.ts | 175 +++++++++++------- .../command-coverage/declarations.ts | 12 +- .../apple-platform-output-guard.test.ts | 8 +- 6 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index 67599fd910..15b02280b0 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -22,11 +22,12 @@ const watch: DeviceInfo = { test('reports a human step after proving the Wear identity and ADB transport', async () => { const runAdb = vi.fn(async (_device, args: string[]) => ({ - stdout: args[0] === 'get-state' - ? 'device\n' - : args.includes('getprop') - ? 'watch\n' - : 'feature:android.hardware.type.watch\n', + stdout: + args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', stderr: '', exitCode: 0, })); @@ -49,11 +50,12 @@ test('reports a human step after proving the Wear identity and ADB transport', a test('automatic selection recognizes a booted Wear target without a Wear label', async () => { const unnamedWearable = { ...watch, name: 'Fossil Gen 6' }; const runAdb = vi.fn(async (_device, args: string[]) => ({ - stdout: args[0] === 'get-state' - ? 'device\n' - : args.includes('getprop') - ? 'watch\n' - : 'feature:android.hardware.type.watch\n', + stdout: + args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', stderr: '', exitCode: 0, })); @@ -124,6 +126,49 @@ test('terminates a wearable emulator launched by a request that does not become expect(terminate).toHaveBeenCalledWith(42); }); +test('boots a stopped Wear emulator, rediscovers it, then proves its ADB identity', async () => { + const stopped = { ...watch, id: 'Wear_OS_Large_Round', booted: false }; + const launched: number[] = []; + let discoveries = 0; + const runAdb = vi.fn(async (_device, args: string[]) => ({ + stdout: + args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + })); + const result = await pairAndroidWearable( + host({ + discover: async () => { + discoveries += 1; + return [phone, discoveries === 1 ? stopped : { ...stopped, booted: true }]; + }, + runAdb, + launch: (_name) => { + launched.push(42); + return 42; + }, + }), + phone, + { wearable: { deviceId: stopped.id }, boot: true }, + signal(), + ); + + expect(launched).toEqual([42]); + expect(discoveries).toBe(2); + expect(result.wearable.booted).toBe(true); + expect(result.status).toBe('human-step-required'); + expect(runAdb).toHaveBeenCalledWith( + { ...stopped, booted: true }, + ['get-state'], + expect.anything(), + expect.anything(), + ); +}); + function host(overrides: { discover: () => Promise; runAdb?: PlatformRuntimeHost['androidTools']['runAdb']; diff --git a/packages/platform-apple/src/wearable-pairing.test.ts b/packages/platform-apple/src/wearable-pairing.test.ts index e21a0794ad..637f84eb22 100644 --- a/packages/platform-apple/src/wearable-pairing.test.ts +++ b/packages/platform-apple/src/wearable-pairing.test.ts @@ -89,6 +89,64 @@ test('rolls back only a pair created by the failed request', async () => { expect(calls.some((args) => args.includes('unpair') && args.includes('pair-1'))).toBe(true); }); +test('boots a stopped watch, pairs it, and rolls back only request-owned resources on failure', async () => { + const calls: string[][] = []; + let pairListCount = 0; + const stoppedInventory = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { name: 'Apple Watch Series 10', udid: 'watch-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }); + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(stoppedInventory); + if (argv.includes('pairs')) { + pairListCount += 1; + const pairs: Record = { + 'existing-pair': { + phone: { udid: 'other-phone' }, + watch: { udid: 'other-watch' }, + state: 'active, connected', + }, + }; + if (pairListCount > 1) { + pairs['pair-1'] = { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: 'paired', + }; + } + return result(JSON.stringify({ pairs })); + } + if (argv.includes('pair_activate')) return result('', 1, 'activation failed'); + if (argv.includes('pair')) return result('pair-1'); + return result(''); + }); + + await expect( + pairAppleWearable( + host(run), + phone, + { wearable: { deviceId: 'watch-1' }, boot: true }, + signal(), + ), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + + const bootIndex = calls.findIndex((args) => args.includes('boot') && args.includes('watch-1')); + const bootStatusIndex = calls.findIndex((args) => args.includes('bootstatus')); + const pairIndex = calls.findIndex((args) => args.includes('pair') && args.includes('watch-1')); + expect(bootIndex).toBeGreaterThanOrEqual(0); + expect(bootStatusIndex).toBeGreaterThan(bootIndex); + expect(pairIndex).toBeGreaterThan(bootStatusIndex); + expect(calls.filter((args) => args.includes('unpair'))).toHaveLength(1); + expect(calls.find((args) => args.includes('unpair'))).toContain('pair-1'); + expect(calls.find((args) => args.includes('unpair'))).not.toContain('existing-pair'); + expect(calls.some((args) => args.includes('shutdown') && args.includes('watch-1'))).toBe(true); +}); + test('does not treat inactive or disconnected pair states as active', async () => { const calls: string[][] = []; const run = vi.fn(async ({ args }: { args: readonly string[] }) => { diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index c8ac6d07ac..cb50bc2916 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1167,10 +1167,7 @@ function createStubClient(params: { const command = createThrowingMethodGroup(); return { command, - devices: { - ...createThrowingMethodGroup(), - list: async () => [], - }, + devices: createThrowingMethodGroup({ list: async () => [] }), sessions: { list: async () => [], stateDir: async () => '/tmp/agent-device-state', @@ -1277,11 +1274,11 @@ function createStubClient(params: { }; } -function createThrowingMethodGroup(): T { +function createThrowingMethodGroup(initial: Partial = {}): T { const unexpectedCommandCall = async (): Promise => { throw new Error('unexpected command call'); }; - return new Proxy({} as Partial, { + return new Proxy(initial, { get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, }) as T; } diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index e0c9da203a..f4a5fa6313 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -462,12 +462,12 @@ test('pair-wearable returns both devices and the exact pairing status', async () owner: localRuntimeOwner('apple'), facts, operations: { - pairWearable: async () => ({ + pairWearable: vi.fn(async (_input) => ({ pairId: 'pair-1', phone, wearable, - status: 'connected', - }), + status: 'connected' as const, + })), }, [Symbol.asyncDispose]: async () => undefined, }; @@ -481,7 +481,11 @@ test('pair-wearable returns both devices and the exact pairing status', async () session: 'default', command: 'pair-wearable', positionals: [], - input: { phone: { platform: 'ios', deviceId: phone.id }, boot: true }, + input: { + phone: { platform: 'ios', deviceId: phone.id }, + wearable: { deviceId: wearable.id }, + boot: true, + }, }, sessionName: 'default', sessionStore: makeSessionStore('agent-device-session-state-'), @@ -519,6 +523,9 @@ test('pair-wearable returns both devices and the exact pairing status', async () status: 'connected', }, }); + const pairWearable = binding.operations.pairWearable; + expect(pairWearable).toHaveBeenCalledOnce(); + expect(pairWearable).toHaveBeenCalledWith({ wearable: { deviceId: wearable.id }, boot: true }); }); test('pair-wearable rejects malformed wearable selector fields instead of dropping them', async () => { @@ -541,79 +548,107 @@ test('pair-wearable rejects malformed wearable selector fields instead of droppi ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); }); -test('pair-wearable rejects a watchOS simulator as an iOS phone before dispatch', async () => { - const watch: DeviceInfo = { - platform: 'apple', - id: 'watch-1', - name: 'Apple Watch', - kind: 'simulator', - target: 'mobile', - appleOs: 'watchos', - booted: true, - }; - const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const; - const facts = createUnavailablePlatformRuntimeFacts(watch, localRuntimeOwner('apple'), { - appLog: unavailable, - network: unavailable, - screenshot: unavailable, - viewport: unavailable, - focus: unavailable, - gesture: unavailable, - scroll: unavailable, - typeText: unavailable, - touch: unavailable, - elementText: unavailable, - back: unavailable, - orientation: unavailable, - fold: unavailable, - tvRemote: unavailable, - keyboard: unavailable, - clipboard: unavailable, - systemButton: unavailable, - triggerAppEvent: unavailable, - settings: unavailable, - readAlert: unavailable, - awaitAlert: unavailable, - acceptAlert: unavailable, - dismissAlert: unavailable, - audioProbeCapture: unavailable, - audioProbeQuery: unavailable, - wearablePairing: unavailable, - lifecycle: applicationLifecycleOperationFacts({ - resolveOpenTarget: unavailable, - prepareApplicationOpen: unavailable, - openApplication: unavailable, - applyRuntimeHints: unavailable, - clearRuntimeHints: unavailable, - closeApplication: unavailable, - finalizeApplicationClose: unavailable, - prepareAppleRunner: unavailable, - configureProviderPortReverse: unavailable, - }), - }); - const bindDevice = vi.fn(); +test.each([ + ['watchOS', { name: 'Apple Watch', appleOs: 'watchos', target: 'mobile', kind: 'simulator' }], + ['macOS', { name: 'Mac', appleOs: 'macos', target: 'desktop', kind: 'device' }], + ['tvOS', { name: 'Apple TV', appleOs: 'tvos', target: 'tv', kind: 'simulator' }], + ['visionOS', { name: 'Apple Vision', appleOs: 'visionos', target: 'mobile', kind: 'simulator' }], +] as const)( + 'pair-wearable rejects unsupported Apple phone OS leaves: %s before dispatch', + async (_name, leaf) => { + const unsupportedPhone: DeviceInfo = { + platform: 'apple', + id: 'unsupported-apple-phone', + ...leaf, + booted: true, + }; + const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const; + const facts = createUnavailablePlatformRuntimeFacts( + unsupportedPhone, + localRuntimeOwner('apple'), + { + appLog: unavailable, + network: unavailable, + screenshot: unavailable, + viewport: unavailable, + focus: unavailable, + gesture: unavailable, + scroll: unavailable, + typeText: unavailable, + touch: unavailable, + elementText: unavailable, + back: unavailable, + orientation: unavailable, + fold: unavailable, + tvRemote: unavailable, + keyboard: unavailable, + clipboard: unavailable, + systemButton: unavailable, + triggerAppEvent: unavailable, + settings: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, + audioProbeCapture: unavailable, + audioProbeQuery: unavailable, + wearablePairing: unavailable, + lifecycle: applicationLifecycleOperationFacts({ + resolveOpenTarget: unavailable, + prepareApplicationOpen: unavailable, + openApplication: unavailable, + applyRuntimeHints: unavailable, + clearRuntimeHints: unavailable, + closeApplication: unavailable, + finalizeApplicationClose: unavailable, + prepareAppleRunner: unavailable, + configureProviderPortReverse: unavailable, + }), + }, + ); + const bindDevice = vi.fn(); - const result = withTestDeviceInventory( - { local: async () => [watch] }, - async () => - await handleSessionStateCommands({ + const result = withTestDeviceInventory( + { local: async () => [unsupportedPhone] }, + async () => + await handleSessionStateCommands({ + req: { + token: 't', + session: 'default', + command: 'pair-wearable', + positionals: [], + input: { phone: { platform: 'ios', deviceId: unsupportedPhone.id }, boot: false }, + }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-session-state-'), + inspectFacts: async () => facts, + bindDevice: bindDevice as BindDeviceRuntime, + }), + ); + + await expect(result).resolves.toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION' }, + }); + expect(bindDevice).not.toHaveBeenCalled(); + }, +); + +test.each(['web', 'linux'])( + 'pair-wearable rejects non-Apple phone platforms at input validation: %s', + async (platform) => { + await expect( + handleSessionStateCommands({ req: { token: 't', session: 'default', command: 'pair-wearable', positionals: [], - input: { phone: { platform: 'ios', deviceId: watch.id }, boot: false }, + input: { phone: { platform, deviceId: 'device-1' }, boot: false }, }, sessionName: 'default', sessionStore: makeSessionStore('agent-device-session-state-'), - inspectFacts: async () => facts, - bindDevice: bindDevice as BindDeviceRuntime, }), - ); - - await expect(result).resolves.toMatchObject({ - ok: false, - error: { code: 'UNSUPPORTED_OPERATION' }, - }); - expect(bindDevice).not.toHaveBeenCalled(); -}); + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + }, +); diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index a5956840a1..463adaeea7 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -208,23 +208,23 @@ const COMMAND_COVERAGE_DECLARATIONS = { ), macos: macos.contract( 'src/daemon/handlers/__tests__/session-state.test.ts', - 'pair-wearable rejects unsupported platform leaves before dispatch', + 'pair-wearable rejects unsupported Apple phone OS leaves', 'macOS pairing is refused by runtime admission', ), tvos: tvos.contract( 'src/daemon/handlers/__tests__/session-state.test.ts', - 'pair-wearable rejects unsupported platform leaves before dispatch', + 'pair-wearable rejects unsupported Apple phone OS leaves', 'tvOS cannot be selected as the phone endpoint', ), web: web.contract( 'src/daemon/handlers/__tests__/session-state.test.ts', - 'pair-wearable rejects unsupported platform leaves before dispatch', - 'web pairing is refused by runtime admission', + 'pair-wearable rejects non-Apple phone platforms at input validation', + 'web is rejected by the endpoint contract before device resolution', ), linux: linux.contract( 'src/daemon/handlers/__tests__/session-state.test.ts', - 'pair-wearable rejects unsupported platform leaves before dispatch', - 'Linux pairing is refused by runtime admission', + 'pair-wearable rejects non-Apple phone platforms at input validation', + 'Linux is rejected by the endpoint contract before device resolution', ), }, [C.shutdown]: { diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index be7cab8737..7170543203 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -6,6 +6,7 @@ import { test } from 'vitest'; import type { AppleRunnerProvider } from '@agent-device/platform-apple/runner'; import type { AppleSimulatorScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { AppError } from '@agent-device/kernel/errors'; import { PROVIDER_SCENARIO_IOS_SIMULATOR, PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; import { createProviderIosSimulatorRecordingProcess, @@ -72,7 +73,10 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.devices]: () => one(), [PUBLIC_COMMANDS.doctor]: () => one(), [PUBLIC_COMMANDS.boot]: () => one(), - [PUBLIC_COMMANDS.pairWearable]: () => one(['sim-1', 'watch-1'], { platform: 'ios', boot: false }), + [PUBLIC_COMMANDS.pairWearable]: () => [ + ...one(['sim-1', 'watch-1'], { platform: 'ios', boot: false }), + ...one(['sim-1', 'watch-1'], { platform: 'ios', boot: true }), + ], [PUBLIC_COMMANDS.prepare]: () => one(['ios-runner']), [PUBLIC_COMMANDS.snapshot]: () => one([], { snapshotInteractiveOnly: true }), [PUBLIC_COMMANDS.perf]: () => [{ positionals: [] }, { positionals: ['frames'] }], @@ -480,7 +484,7 @@ test('wearable pairing reaches the provider-backed Apple runtime boundary', asyn wearable: { deviceId: 'watch-1' }, boot: false, }), - (error: unknown) => error instanceof Error && /watchOS simulator/i.test(error.message), + (error: unknown) => error instanceof AppError && error.code === 'DEVICE_NOT_FOUND', ); } finally { await daemon.close(); From 7e554cbfb5a4c6fdfa60563e5fb9f54cd1007e76 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:11:27 -0700 Subject: [PATCH 13/15] test: drive wearable inputs through provider guard --- .../apple-platform-output-guard.test.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index 7170543203..cdce6ede7e 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -49,7 +49,11 @@ const WORLDS: Record = { macos: { leaf: 'macos', open: { positionals: ['settings'], flags: { platform: 'macos' } } }, }; -type DriveStep = { positionals?: string[]; flags?: Record }; +type DriveStep = { + positionals?: string[]; + flags?: Record; + input?: import('../../../src/daemon/daemon-request.ts').DaemonRequest['input']; +}; type DriveContext = { world: World; leaf: PublicLeaf; tmpDir: string; appPath: string }; type DriveSpec = (ctx: DriveContext) => DriveStep[]; @@ -74,8 +78,20 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.doctor]: () => one(), [PUBLIC_COMMANDS.boot]: () => one(), [PUBLIC_COMMANDS.pairWearable]: () => [ - ...one(['sim-1', 'watch-1'], { platform: 'ios', boot: false }), - ...one(['sim-1', 'watch-1'], { platform: 'ios', boot: true }), + { + input: { + phone: { platform: 'ios', deviceId: 'sim-1' }, + wearable: { deviceId: 'watch-1' }, + boot: false, + }, + }, + { + input: { + phone: { platform: 'ios', deviceId: 'sim-1' }, + wearable: { deviceId: 'watch-1' }, + boot: true, + }, + }, ], [PUBLIC_COMMANDS.prepare]: () => one(['ios-runner']), [PUBLIC_COMMANDS.snapshot]: () => one([], { snapshotInteractiveOnly: true }), @@ -418,7 +434,9 @@ async function runWorldGuard(world: World): Promise { for (const step of DRIVEN_COMMANDS[command]!(ctx)) { await ensureSession(daemon, world); const response = await withCommandTimeout( - daemon.callCommand(command, step.positionals ?? [], step.flags ?? {}), + daemon.callCommand(command, step.positionals ?? [], step.flags ?? {}, { + ...(step.input === undefined ? {} : { input: step.input }), + }), command, ); const out = { From 56390156284b41754ade03ce6ced793c63992909 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:40:04 -0700 Subject: [PATCH 14/15] fix(android): address wearable pairing review --- .../kernel/src/device-selector-flags.test.ts | 2 + .../src/wearable-pairing.test.ts | 84 ++++++++++++++- .../platform-android/src/wearable-pairing.ts | 102 +++++++++++------- src/commands/command-contract.ts | 1 + src/commands/management/device.test.ts | 11 ++ src/commands/management/device.ts | 4 +- 6 files changed, 165 insertions(+), 39 deletions(-) create mode 100644 src/commands/management/device.test.ts diff --git a/packages/kernel/src/device-selector-flags.test.ts b/packages/kernel/src/device-selector-flags.test.ts index 3f80b19e22..c924a99a03 100644 --- a/packages/kernel/src/device-selector-flags.test.ts +++ b/packages/kernel/src/device-selector-flags.test.ts @@ -62,6 +62,8 @@ test('--platform ios excludes a booted watchOS simulator from phone selection', booted: true, }; assert.equal((await resolveDevice([APPLE, watch], { platform: 'ios' })).id, APPLE.id); + assert.equal((await resolveDevice([APPLE, watch], {})).id, APPLE.id); + assert.equal((await resolveDevice([APPLE, watch], { platform: 'apple' })).id, APPLE.id); assert.equal((await resolveError([watch], { platform: 'ios' })).code, 'DEVICE_NOT_FOUND'); }); diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index 15b02280b0..24253a3891 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { pairAndroidWearable } from './wearable-pairing.ts'; const phone: DeviceInfo = { @@ -68,7 +69,45 @@ test('automatic selection recognizes a booted Wear target without a Wear label', expect(result.wearable.name).toBe('Fossil Gen 6'); expect(runAdb).toHaveBeenCalledWith( unnamedWearable, - ['shell', 'pm', 'list', 'features'], + deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), + expect.anything(), + expect.anything(), + ); +}); + +test('automatic selection skips an unresponsive candidate and keeps scanning for Wear features', async () => { + const unresponsive = { ...watch, id: 'emulator-5558', name: 'Android Device' }; + const unnamedWearable = { ...watch, name: 'Fossil Gen 6' }; + const runAdb = vi.fn(async (device: DeviceInfo, args: readonly string[]) => { + if (args[0] === 'get-state') return { stdout: 'device\n', stderr: '', exitCode: 0 }; + if (args.includes('getprop')) return { stdout: 'watch\n', stderr: '', exitCode: 0 }; + if (device.id === unresponsive.id) { + return { stdout: '', stderr: 'device offline', exitCode: 1 }; + } + return { + stdout: 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + }; + }); + + const result = await pairAndroidWearable( + host({ discover: async () => [phone, unresponsive, unnamedWearable], runAdb }), + phone, + { boot: false }, + signal(), + ); + + expect(result.wearable.id).toBe(unnamedWearable.id); + expect(runAdb).toHaveBeenCalledWith( + unresponsive, + deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), + expect.anything(), + expect.anything(), + ); + expect(runAdb).toHaveBeenCalledWith( + unnamedWearable, + ['get-state'], expect.anything(), expect.anything(), ); @@ -169,6 +208,49 @@ test('boots a stopped Wear emulator, rediscovers it, then proves its ADB identit ); }); +test('Wear boot polling cannot replace the launched emulator with a same-named physical watch', async () => { + const stopped = { ...watch, id: 'Wear_OS_Large_Round', booted: false }; + const physicalWatch = { ...stopped, id: 'physical-watch', kind: 'device' as const, booted: true }; + const bootedEmulator = { ...stopped, booted: true }; + let discoveries = 0; + const runAdb = vi.fn(async (_device: DeviceInfo, args: readonly string[]) => ({ + stdout: + args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + })); + const terminate = vi.fn(async () => {}); + + const result = await pairAndroidWearable( + host({ + discover: async () => { + discoveries += 1; + return discoveries === 1 ? [phone, stopped] : [phone, physicalWatch, bootedEmulator]; + }, + runAdb, + launch: () => 42, + terminate, + }), + phone, + { wearable: { deviceId: stopped.id }, boot: true }, + signal(), + ); + + expect(result.wearable).toMatchObject({ id: stopped.id, kind: 'emulator', booted: true }); + expect(runAdb).toHaveBeenCalledWith( + bootedEmulator, + ['get-state'], + expect.anything(), + expect.anything(), + ); + expect(runAdb.mock.calls.some(([device]) => device.id === physicalWatch.id)).toBe(false); + expect(terminate).not.toHaveBeenCalled(); +}); + function host(overrides: { discover: () => Promise; runAdb?: PlatformRuntimeHost['androidTools']['runAdb']; diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index e47037004d..f4236f7ea0 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -4,6 +4,7 @@ import type { WearablePairingRuntimeResult, } from '@agent-device/contracts/wearable-pairing-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; const DISCOVERY_ATTEMPTS = 60; @@ -24,29 +25,38 @@ export async function pairAndroidWearable( throw new AppError( 'UNSUPPORTED_OPERATION', 'Pairing is supported only with Android Wear emulators; physical Wear pairing is not automated.', - { hint: 'Use an Android Wear emulator and complete the phone-side companion setup as the reported human step.' }, + { + hint: 'Use an Android Wear emulator and complete the phone-side companion setup as the reported human step.', + }, ); } if (input.boot && wearable.booted !== true) { - if (wearable.kind !== 'emulator') { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Only a Wear emulator can be booted automatically.', - ); - } launchedPid = host.deviceReadiness.androidEmulator.launch(wearable.name, false); for (let attempt = 0; attempt < DISCOVERY_ATTEMPTS; attempt += 1) { signal.throwIfAborted(); await host.clock.sleep(1_000, signal); devices = await discover(host, signal); - const refreshed = devices.find( - (candidate) => candidate.id === wearable.id || candidate.name === wearable.name, - ); + const refreshed = + devices.find( + (candidate) => candidate.kind === 'emulator' && candidate.id === wearable.id, + ) ?? + (() => { + const sameNameEmulators = devices.filter( + (candidate) => candidate.kind === 'emulator' && candidate.name === wearable.name, + ); + return sameNameEmulators.length === 1 ? sameNameEmulators[0] : undefined; + })(); if (refreshed?.booted) { wearable = refreshed; break; } } + if (wearable.kind !== 'emulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Only a Wear emulator can be booted automatically.', + ); + } if (!wearable.booted) { throw new AppError('COMMAND_FAILED', 'Wear emulator did not finish booting.'); } @@ -65,13 +75,13 @@ export async function pairAndroidWearable( const [characteristics, features] = await Promise.all([ host.androidTools.runAdb( wearable, - ['shell', 'getprop', 'ro.build.characteristics'], + deviceShellArgv('adb', 'shell', ['getprop', 'ro.build.characteristics']), { allowFailure: true, timeoutMs: 10_000 }, signal, ), host.androidTools.runAdb( wearable, - ['shell', 'pm', 'list', 'features'], + deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), { allowFailure: true, timeoutMs: 10_000 }, signal, ), @@ -79,13 +89,8 @@ export async function pairAndroidWearable( const hasWatchCharacteristic = characteristics.stdout .split(/[\s,]+/) .some((value) => value.toLowerCase() === 'watch'); - const hasWatchFeature = /^feature:android\.hardware\.type\.watch\s*$/im.test( - features.stdout, - ); - if ( - characteristics.exitCode !== 0 && - features.exitCode !== 0 - ) { + const hasWatchFeature = /^feature:android\.hardware\.type\.watch\s*$/im.test(features.stdout); + if (characteristics.exitCode !== 0 && features.exitCode !== 0) { throw new AppError( 'COMMAND_FAILED', 'Unable to verify the selected Android target is a Wear device.', @@ -95,7 +100,9 @@ export async function pairAndroidWearable( throw new AppError( 'UNSUPPORTED_OPERATION', 'The selected Android target does not identify itself as a Wear device.', - { hint: 'Select a Wear OS target with the watch build characteristic or hardware feature.' }, + { + hint: 'Select a Wear OS target with the watch build characteristic or hardware feature.', + }, ); } } else { @@ -151,28 +158,47 @@ async function selectWearable( if (!requested) { const runningCandidates = candidates.filter((device) => device.booted === true); const featureMatches: DeviceInfo[] = []; + const probeFailures: string[] = []; + let probedCandidates = 0; for (const device of runningCandidates) { - const result = await host.androidTools.runAdb( - device, - ['shell', 'pm', 'list', 'features'], - { allowFailure: true, timeoutMs: 10_000 }, - signal, - ); - if (result.exitCode !== 0) { - throw new AppError( - 'COMMAND_FAILED', - `Unable to inspect Android target ${device.id} for Wear OS features.`, + let result: Awaited>; + try { + result = await host.androidTools.runAdb( + device, + deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), + { allowFailure: true, timeoutMs: 10_000 }, + signal, ); + } catch { + signal.throwIfAborted(); + probeFailures.push(device.id); + continue; } + if (result.exitCode !== 0) { + probeFailures.push(device.id); + continue; + } + probedCandidates += 1; if (/^feature:android\.hardware\.type\.watch\s*$/im.test(result.stdout)) { featureMatches.push(device); } } if (featureMatches.length === 1) return { ...featureMatches[0]! }; if (featureMatches.length > 1) { - throw new AppError('INVALID_ARGS', 'More than one Wear OS target matches; provide deviceId or name.', { - candidates: featureMatches.map(({ id, name }) => ({ id, name })), - }); + throw new AppError( + 'INVALID_ARGS', + 'More than one Wear OS target matches; provide deviceId or name.', + { + candidates: featureMatches.map(({ id, name }) => ({ id, name })), + }, + ); + } + if (runningCandidates.length > 0 && probedCandidates === 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Unable to inspect any running Android target for Wear OS features.', + { deviceIds: probeFailures }, + ); } const stoppedEmulators = candidates.filter( (device) => device.kind === 'emulator' && device.booted === false, @@ -182,7 +208,11 @@ async function selectWearable( throw new AppError('DEVICE_NOT_FOUND', 'No matching Wear OS device or emulator is available.'); } const matches = requested ? candidates : namedWearables; - throw new AppError('INVALID_ARGS', 'More than one Wear OS target matches; provide deviceId or name.', { - candidates: matches.map(({ id, name }) => ({ id, name })), - }); + throw new AppError( + 'INVALID_ARGS', + 'More than one Wear OS target matches; provide deviceId or name.', + { + candidates: matches.map(({ id, name }) => ({ id, name })), + }, + ); } diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index f01c8f2612..2a4bf8ef14 100644 --- a/src/commands/command-contract.ts +++ b/src/commands/command-contract.ts @@ -9,6 +9,7 @@ export type JsonSchema = { items?: JsonSchema; minItems?: number; maxItems?: number; + minLength?: number; prefixItems?: readonly JsonSchema[]; oneOf?: readonly JsonSchema[]; not?: JsonSchema; diff --git a/src/commands/management/device.test.ts b/src/commands/management/device.test.ts new file mode 100644 index 0000000000..bfcc0cf908 --- /dev/null +++ b/src/commands/management/device.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest'; +import { deviceManagementCommandFacets } from './device.ts'; + +test('pair-wearable selector schema rejects empty wearable identity fields', () => { + const command = deviceManagementCommandFacets.find((facet) => facet.name === 'pair-wearable'); + const wearable = command?.metadata.inputSchema.properties?.wearable; + const properties = wearable?.properties; + + expect(properties?.deviceId?.minLength).toBe(1); + expect(properties?.name?.minLength).toBe(1); +}); diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 25657299a4..06552cb6cf 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -55,8 +55,8 @@ const pairWearableCommandMetadata = defineFieldCommandMetadata( type: 'object', description: 'Optional wearable selector by deviceId, name, or both.', properties: { - deviceId: { type: 'string' }, - name: { type: 'string' }, + deviceId: { type: 'string', minLength: 1 }, + name: { type: 'string', minLength: 1 }, }, oneOf: [ { required: ['deviceId'], not: { required: ['name'] } }, From d58a79ebbc1b266540ca7e93ec0ff4b56cbb45b2 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 17:44:39 -0700 Subject: [PATCH 15/15] fix: tighten wearable pairing cleanup and scoping --- .../contracts/src/wearable-pairing-runtime.ts | 5 +- .../src/wearable-pairing.test.ts | 33 +++- .../platform-android/src/wearable-pairing.ts | 175 +++++++++++------- packages/platform-apple/src/runtime.test.ts | 17 +- .../src/wearable-pairing.test.ts | 104 +++++++++++ .../platform-apple/src/wearable-pairing.ts | 91 ++++++--- src/__tests__/wearable-pairing.coverage.ts | 2 +- src/daemon/handlers/session-state.ts | 20 +- .../apple-platform-output-guard.test.ts | 5 +- test/integration/smoke-web-platform.test.ts | 16 ++ 10 files changed, 362 insertions(+), 106 deletions(-) diff --git a/packages/contracts/src/wearable-pairing-runtime.ts b/packages/contracts/src/wearable-pairing-runtime.ts index 4e68659ec0..b85f8e9639 100644 --- a/packages/contracts/src/wearable-pairing-runtime.ts +++ b/packages/contracts/src/wearable-pairing-runtime.ts @@ -6,13 +6,14 @@ export type WearablePairingEndpoint = Readonly<{ }>; export type WearableSelector = Readonly< - | { deviceId: string; name?: string } - | { deviceId?: string; name: string } + { deviceId: string; name?: string } | { deviceId?: string; name: string } >; export type PairWearableInput = Readonly<{ wearable?: WearableSelector; boot: boolean; + /** Daemon-derived inventory boundary; never copy this from command input. */ + androidSerialAllowlist?: readonly string[]; }>; export type WearablePairingStatus = 'connected' | 'paired' | 'human-step-required'; diff --git a/packages/platform-android/src/wearable-pairing.test.ts b/packages/platform-android/src/wearable-pairing.test.ts index 24253a3891..42f8c8d077 100644 --- a/packages/platform-android/src/wearable-pairing.test.ts +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -48,6 +48,37 @@ test('reports a human step after proving the Wear identity and ADB transport', a expect(runAdb).toHaveBeenCalledWith(watch, ['get-state'], expect.anything(), expect.anything()); }); +test('keeps Wear discovery inside the daemon-provided Android device boundary', async () => { + const androidSerialAllowlist = ['emulator-5556']; + const discover = vi.fn(async (_input?: unknown) => [phone, watch]); + const runAdb = vi.fn(async (_device, args: string[]) => ({ + stdout: + args[0] === 'get-state' + ? 'device\n' + : args.includes('getprop') + ? 'watch\n' + : 'feature:android.hardware.type.watch\n', + stderr: '', + exitCode: 0, + })); + + await pairAndroidWearable( + host({ discover, runAdb }), + phone, + { boot: false, androidSerialAllowlist }, + signal(), + ); + + expect(discover).toHaveBeenCalledWith( + { + platform: 'android', + androidAvdSelection: 'include-stopped', + androidSerialAllowlist, + }, + expect.anything(), + ); +}); + test('automatic selection recognizes a booted Wear target without a Wear label', async () => { const unnamedWearable = { ...watch, name: 'Fossil Gen 6' }; const runAdb = vi.fn(async (_device, args: string[]) => ({ @@ -252,7 +283,7 @@ test('Wear boot polling cannot replace the launched emulator with a same-named p }); function host(overrides: { - discover: () => Promise; + discover: PlatformRuntimeHost['deviceReadiness']['androidEmulator']['discover']; runAdb?: PlatformRuntimeHost['androidTools']['runAdb']; launch?: (name: string, headless: boolean) => number; terminate?: (pid: number) => Promise; diff --git a/packages/platform-android/src/wearable-pairing.ts b/packages/platform-android/src/wearable-pairing.ts index f4236f7ea0..abdc7329b8 100644 --- a/packages/platform-android/src/wearable-pairing.ts +++ b/packages/platform-android/src/wearable-pairing.ts @@ -17,7 +17,7 @@ export async function pairAndroidWearable( input: PairWearableInput, signal: AbortSignal, ): Promise { - let devices = await discover(host, signal); + let devices = await discover(host, signal, input.androidSerialAllowlist); let wearable = await selectWearable(host, devices, phone, input, signal); let launchedPid: number | undefined; try { @@ -35,7 +35,7 @@ export async function pairAndroidWearable( for (let attempt = 0; attempt < DISCOVERY_ATTEMPTS; attempt += 1) { signal.throwIfAborted(); await host.clock.sleep(1_000, signal); - devices = await discover(host, signal); + devices = await discover(host, signal, input.androidSerialAllowlist); const refreshed = devices.find( (candidate) => candidate.kind === 'emulator' && candidate.id === wearable.id, @@ -129,9 +129,17 @@ export async function pairAndroidWearable( } } -async function discover(host: PlatformRuntimeHost, signal: AbortSignal) { +async function discover( + host: PlatformRuntimeHost, + signal: AbortSignal, + androidSerialAllowlist?: readonly string[], +) { return await host.deviceReadiness.androidEmulator.discover( - { platform: 'android', androidAvdSelection: 'include-stopped' }, + { + platform: 'android', + androidAvdSelection: 'include-stopped', + ...(androidSerialAllowlist ? { androidSerialAllowlist: [...androidSerialAllowlist] } : {}), + }, signal, ); } @@ -143,76 +151,115 @@ async function selectWearable( input: PairWearableInput, signal: AbortSignal, ): Promise { - const requested = input.wearable; - const candidates = devices.filter( + const candidates = findWearableCandidates(devices, phone, input.wearable); + const directMatch = selectRequestedOrNamedWearable(candidates, input.wearable); + if (directMatch) return directMatch; + if (input.wearable) throwNoWearable(); + return await selectWearableByRuntimeEvidence(host, candidates, input.boot, signal); +} + +function findWearableCandidates( + devices: readonly DeviceInfo[], + phone: DeviceInfo, + requested: PairWearableInput['wearable'], +): DeviceInfo[] { + return devices.filter( (device) => device.id !== phone.id && device.target !== 'tv' && (!requested?.deviceId || device.id === requested.deviceId) && (!requested?.name || device.name === requested.name), ); - const namedWearables = candidates.filter((device) => /\b(?:wear|watch)\b/i.test(device.name)); - if (requested && candidates.length === 1) return { ...candidates[0]! }; - if (!requested && namedWearables.length === 1) return { ...namedWearables[0]! }; - if (candidates.length === 0 || (!requested && namedWearables.length === 0)) { - if (!requested) { - const runningCandidates = candidates.filter((device) => device.booted === true); - const featureMatches: DeviceInfo[] = []; - const probeFailures: string[] = []; - let probedCandidates = 0; - for (const device of runningCandidates) { - let result: Awaited>; - try { - result = await host.androidTools.runAdb( - device, - deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), - { allowFailure: true, timeoutMs: 10_000 }, - signal, - ); - } catch { - signal.throwIfAborted(); - probeFailures.push(device.id); - continue; - } - if (result.exitCode !== 0) { - probeFailures.push(device.id); - continue; - } - probedCandidates += 1; - if (/^feature:android\.hardware\.type\.watch\s*$/im.test(result.stdout)) { - featureMatches.push(device); - } - } - if (featureMatches.length === 1) return { ...featureMatches[0]! }; - if (featureMatches.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'More than one Wear OS target matches; provide deviceId or name.', - { - candidates: featureMatches.map(({ id, name }) => ({ id, name })), - }, - ); - } - if (runningCandidates.length > 0 && probedCandidates === 0) { - throw new AppError( - 'COMMAND_FAILED', - 'Unable to inspect any running Android target for Wear OS features.', - { deviceIds: probeFailures }, - ); - } - const stoppedEmulators = candidates.filter( - (device) => device.kind === 'emulator' && device.booted === false, - ); - if (input.boot && stoppedEmulators.length === 1) return { ...stoppedEmulators[0]! }; +} + +function selectRequestedOrNamedWearable( + candidates: readonly DeviceInfo[], + requested: PairWearableInput['wearable'], +): DeviceInfo | undefined { + if (requested) { + if (candidates.length === 1) return { ...candidates[0]! }; + if (candidates.length > 1) throwAmbiguousWearables(candidates); + return undefined; + } + + const named = candidates.filter((device) => /\b(?:wear|watch)\b/i.test(device.name)); + if (named.length === 1) return { ...named[0]! }; + if (named.length > 1) throwAmbiguousWearables(named); + return undefined; +} + +async function selectWearableByRuntimeEvidence( + host: PlatformRuntimeHost, + candidates: readonly DeviceInfo[], + shouldBoot: boolean, + signal: AbortSignal, +): Promise { + const runningCandidates = candidates.filter((device) => device.booted === true); + const probe = await probeWearableFeatures(host, runningCandidates, signal); + if (probe.matches.length === 1) return { ...probe.matches[0]! }; + if (probe.matches.length > 1) throwAmbiguousWearables(probe.matches); + if (runningCandidates.length > 0 && probe.succeeded === 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Unable to inspect any running Android target for Wear OS features.', + { deviceIds: probe.failures }, + ); + } + + const stoppedEmulators = candidates.filter( + (device) => device.kind === 'emulator' && device.booted === false, + ); + if (shouldBoot && stoppedEmulators.length === 1) return { ...stoppedEmulators[0]! }; + throwNoWearable(); +} + +async function probeWearableFeatures( + host: PlatformRuntimeHost, + devices: readonly DeviceInfo[], + signal: AbortSignal, +): Promise<{ matches: DeviceInfo[]; failures: string[]; succeeded: number }> { + const matches: DeviceInfo[] = []; + const failures: string[] = []; + let succeeded = 0; + for (const device of devices) { + const features = await readWearFeatures(host, device, signal); + if (features === undefined) { + failures.push(device.id); + continue; } - throw new AppError('DEVICE_NOT_FOUND', 'No matching Wear OS device or emulator is available.'); + succeeded += 1; + if (/^feature:android\.hardware\.type\.watch\s*$/im.test(features)) matches.push(device); } - const matches = requested ? candidates : namedWearables; + return { matches, failures, succeeded }; +} + +async function readWearFeatures( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + try { + const result = await host.androidTools.runAdb( + device, + deviceShellArgv('adb', 'shell', ['pm', 'list', 'features']), + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ); + return result.exitCode === 0 ? result.stdout : undefined; + } catch { + signal.throwIfAborted(); + return undefined; + } +} + +function throwAmbiguousWearables(devices: readonly DeviceInfo[]): never { throw new AppError( 'INVALID_ARGS', 'More than one Wear OS target matches; provide deviceId or name.', - { - candidates: matches.map(({ id, name }) => ({ id, name })), - }, + { candidates: devices.map(({ id, name }) => ({ id, name })) }, ); } + +function throwNoWearable(): never { + throw new AppError('DEVICE_NOT_FOUND', 'No matching Wear OS device or emulator is available.'); +} diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 0d5b9a1878..ef2a32b8d5 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -123,13 +123,7 @@ test.each([ expect(facts.device.providerMode).toBe('local'); expectAppStateFact(device, binding); expect(facts.operations.networkDump).toEqual({ available: true }); - expect(facts.operations.pairWearable).toEqual( - device.appleOs === 'ios' || device.appleOs === 'ipados' - ? device.kind === 'simulator' - ? { available: true } - : expect.objectContaining({ available: false, reason: 'unsupported-device-kind' }) - : expect.objectContaining({ available: false, reason: 'unsupported-platform-leaf' }), - ); + expect(facts.operations.pairWearable).toEqual(expectedPairWearableFact(device)); expect(facts.operations.listApps.available).toBe( device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', ); @@ -178,6 +172,15 @@ test.each([ expectAppleSnapshotAvailability(binding, device); }); +function expectedPairWearableFact(device: DeviceInfo): unknown { + if (device.appleOs === 'ios' || device.appleOs === 'ipados') { + return device.kind === 'simulator' + ? { available: true } + : expect.objectContaining({ available: false, reason: 'unsupported-device-kind' }); + } + return expect.objectContaining({ available: false, reason: 'unsupported-platform-leaf' }); +} + function expectApplePerfAvailability( binding: DeviceBinding, available: boolean, diff --git a/packages/platform-apple/src/wearable-pairing.test.ts b/packages/platform-apple/src/wearable-pairing.test.ts index 637f84eb22..04d31d4068 100644 --- a/packages/platform-apple/src/wearable-pairing.test.ts +++ b/packages/platform-apple/src/wearable-pairing.test.ts @@ -172,6 +172,110 @@ test('does not treat inactive or disconnected pair states as active', async () = expect(calls.some((args) => args.includes('pair_activate'))).toBe(true); }); +test('reports an active but disconnected CoreSimulator pair as paired, not connected', async () => { + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + if (args.includes('devices')) return result(watchInventory); + if (args.includes('pairs')) { + return result( + JSON.stringify({ + pairs: { + 'pair-1': { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: '(active, disconnected)', + }, + }, + }), + ); + } + return result(''); + }); + + const paired = await pairAppleWearable(host(run), phone, { boot: false }, signal()); + + expect(paired.status).toBe('paired'); +}); + +test('waits for an already-booting watch without claiming or shutting it down', async () => { + const calls: string[][] = []; + const bootingInventory = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { name: 'Apple Watch Series 10', udid: 'watch-1', state: 'Booting', isAvailable: true }, + ], + }, + }); + const run = vi.fn(async ({ args }: { args: readonly string[] }) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(bootingInventory); + if (argv.includes('pairs')) return result(JSON.stringify({ pairs: {} })); + if (argv.includes('pair_activate')) return result('', 1, 'activation failed'); + if (argv.includes('pair')) return result('pair-1'); + return result(''); + }); + + await expect(pairAppleWearable(host(run), phone, { boot: true }, signal())).rejects.toMatchObject( + { code: 'COMMAND_FAILED' }, + ); + + expect(calls.some((args) => args.includes('boot') && args.includes('watch-1'))).toBe(false); + expect(calls.some((args) => args.includes('bootstatus') && args.includes('watch-1'))).toBe(true); + expect(calls.some((args) => args.includes('shutdown') && args.includes('watch-1'))).toBe(false); +}); + +test('cancellation after simctl pair removes every request-created pair and preserves prior pairs', async () => { + const controller = new AbortController(); + const calls: string[][] = []; + let pairCreated = false; + const pairsJson = () => + JSON.stringify({ + pairs: { + 'unrelated-active-pair': { + phone: { udid: 'other-phone' }, + watch: { udid: 'other-watch' }, + state: '(active, connected)', + }, + ...(pairCreated + ? { + 'new-pair': { + phone: { udid: phone.id }, + watch: { udid: 'watch-1' }, + state: 'paired', + }, + } + : {}), + }, + }); + const run = vi.fn(async ({ args }: { args: readonly string[] }, requestSignal?: AbortSignal) => { + const argv = [...args]; + calls.push(argv); + if (argv.includes('devices')) return result(watchInventory); + if (argv.includes('pairs')) { + if (requestSignal?.aborted) throw requestSignal.reason; + return result(pairsJson()); + } + if (argv.includes('pair') && argv.includes('watch-1')) { + pairCreated = true; + controller.abort(new Error('cancelled during simctl pair')); + return result(''); + } + return result(''); + }); + + await expect( + pairAppleWearable(host(run), phone, { boot: false }, controller.signal), + ).rejects.toThrow('cancelled during simctl pair'); + + expect(calls.some((args) => args.includes('unpair') && args.includes('new-pair'))).toBe(true); + expect( + calls.some((args) => args.includes('unpair') && args.includes('unrelated-active-pair')), + ).toBe(false); + expect( + calls.some((args) => args.includes('pair_activate') && args.includes('unrelated-active-pair')), + ).toBe(true); +}); + function host(run: ReturnType): PlatformRuntimeHost { return { appleTools: { run } } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-apple/src/wearable-pairing.ts b/packages/platform-apple/src/wearable-pairing.ts index d6564c005e..ddc43beb14 100644 --- a/packages/platform-apple/src/wearable-pairing.ts +++ b/packages/platform-apple/src/wearable-pairing.ts @@ -14,6 +14,8 @@ type ListedPair = { state: string; }; +type WatchDeviceInfo = DeviceInfo & { simulatorState: string }; + export async function pairAppleWearable( host: PlatformRuntimeHost, phone: DeviceInfo, @@ -29,10 +31,14 @@ export async function pairAppleWearable( parseWatchDevices(devicesResult.stdout, phone.simulatorSetPath), input, ); - const bootedHere = input.boot && wearable.booted !== true; - let createdPairId: string | undefined; + const preRequestPairs = await listPairs(host, phone, signal); + const preRequestPairIds = new Set(preRequestPairs.map((pair) => pair.pairId)); + const preRequestActivePairs = preRequestPairs.filter((pair) => isPairActive(pair.state)); + const initialWatchState = wearable.simulatorState; + let bootedHere = false; try { - if (bootedHere) { + if (input.boot && initialWatchState === 'Shutdown') { + bootedHere = true; await runRequired( host, phone, @@ -48,9 +54,23 @@ export async function pairAppleWearable( 'watchOS simulator did not finish booting.', ); wearable.booted = true; + } else if (input.boot && initialWatchState === 'Booting') { + await runRequired( + host, + phone, + ['bootstatus', wearable.id, '-b'], + signal, + 'watchOS simulator did not finish booting.', + ); + wearable.booted = true; + } else if (initialWatchState !== 'Booted' && initialWatchState !== 'Shutdown') { + throw new AppError( + 'COMMAND_FAILED', + `Cannot pair a watchOS simulator while it is in state ${initialWatchState}.`, + ); } - let pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); + let pair = findPair(preRequestPairs, phone.id, wearable.id); if (!pair) { await runRequired( host, @@ -60,7 +80,6 @@ export async function pairAppleWearable( 'CoreSimulator could not pair the selected phone and watch.', ); pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); - createdPairId = pair?.pairId; } if (!pair) { throw new AppError( @@ -69,7 +88,7 @@ export async function pairAppleWearable( ); } - if (!hasPairState(pair.state, 'active') && !hasPairState(pair.state, 'connected')) { + if (!isPairActive(pair.state)) { await runRequired( host, phone, @@ -86,18 +105,7 @@ export async function pairAppleWearable( status: hasPairState(pair.state, 'connected') ? 'connected' : 'paired', }; } catch (error) { - if (createdPairId) { - await host.appleTools - .run( - { - tool: 'simctl', - args: scopeSimctlArgsForDevice(phone, ['unpair', createdPairId]), - allowFailure: true, - }, - undefined, - ) - .catch(() => undefined); - } + await rollbackNewPairs(host, phone, preRequestPairIds, preRequestActivePairs); if (bootedHere) { await host.appleTools .run( @@ -114,6 +122,44 @@ export async function pairAppleWearable( } } +function isPairActive(state: string): boolean { + return hasPairState(state, 'active') || hasPairState(state, 'connected'); +} + +async function rollbackNewPairs( + host: PlatformRuntimeHost, + phone: DeviceInfo, + preRequestPairIds: ReadonlySet, + preRequestActivePairs: readonly ListedPair[], +): Promise { + const currentPairs = await listPairs(host, phone).catch(() => []); + for (const pair of currentPairs) { + if (preRequestPairIds.has(pair.pairId)) continue; + await host.appleTools + .run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(phone, ['unpair', pair.pairId]), + allowFailure: true, + }, + undefined, + ) + .catch(() => undefined); + } + for (const pair of preRequestActivePairs) { + await host.appleTools + .run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(phone, ['pair_activate', pair.pairId]), + allowFailure: true, + }, + undefined, + ) + .catch(() => undefined); + } +} + function hasPairState(state: string, expected: 'active' | 'connected'): boolean { return state .toLowerCase() @@ -123,7 +169,7 @@ function hasPairState(state: string, expected: 'active' | 'connected'): boolean // CoreSimulator's nested inventory is normalized here so selection never depends on raw JSON. // fallow-ignore-next-line complexity -function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInfo[] { +function parseWatchDevices(stdout: string, simulatorSetPath?: string): WatchDeviceInfo[] { let payload: { devices?: Record< string, @@ -140,7 +186,7 @@ function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInf error, ); } - const devices: DeviceInfo[] = []; + const devices: WatchDeviceInfo[] = []; for (const [runtime, entries] of Object.entries(payload.devices ?? {})) { if (!runtime.toLowerCase().includes('watchos')) continue; for (const entry of entries) { @@ -153,6 +199,7 @@ function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInf target: 'mobile', appleOs: 'watchos', booted: entry.state === 'Booted', + simulatorState: entry.state ?? 'Unknown', ...(simulatorSetPath ? { simulatorSetPath } : {}), }); } @@ -160,7 +207,7 @@ function parseWatchDevices(stdout: string, simulatorSetPath?: string): DeviceInf return devices; } -function selectWatch(devices: DeviceInfo[], input: PairWearableInput): DeviceInfo { +function selectWatch(devices: WatchDeviceInfo[], input: PairWearableInput): WatchDeviceInfo { const requested = input.wearable; const matches = devices.filter( (device) => @@ -183,7 +230,7 @@ function selectWatch(devices: DeviceInfo[], input: PairWearableInput): DeviceInf async function listPairs( host: PlatformRuntimeHost, phone: DeviceInfo, - signal: AbortSignal, + signal?: AbortSignal, ): Promise { const result = await host.appleTools.run( { tool: 'simctl', args: scopeSimctlArgsForDevice(phone, ['list', 'pairs', '-j']) }, diff --git a/src/__tests__/wearable-pairing.coverage.ts b/src/__tests__/wearable-pairing.coverage.ts index 24fa2b2935..c70f5118a6 100644 --- a/src/__tests__/wearable-pairing.coverage.ts +++ b/src/__tests__/wearable-pairing.coverage.ts @@ -4,5 +4,5 @@ import { defineAndroidContractEvidence } from '../../test/integration/android-em export const ANDROID_WEARABLE_PAIRING_CONTRACT_EVIDENCE = defineAndroidContractEvidence( 'packages/platform-android/src/wearable-pairing.test.ts', [PUBLIC_COMMANDS.pairWearable], - 'reports a human step after proving the Wear ADB transport', + 'reports a human step after proving the Wear identity and ADB transport', ); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 0301074074..e8162bfb6f 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -257,14 +257,15 @@ export async function handleSessionStateCommands(params: { if (req.command === 'pair-wearable') { const input = readPairWearableInput(req.input); + const phoneFlags = + input.phone.platform === 'ios' + ? { ...req.flags, platform: 'apple' as const, udid: input.phone.deviceId } + : { ...req.flags, platform: 'android' as const, serial: input.phone.deviceId }; const device = await resolveCommandDevice({ session: undefined, - flags: - input.phone.platform === 'ios' - // Resolve the exact Apple identity in the family namespace so a watchOS - // simulator can be reported as an unsupported phone target, not as missing. - ? { platform: 'apple', udid: input.phone.deviceId } - : { platform: 'android', serial: input.phone.deviceId }, + // Preserve isolation flags from the admitted request while forcing the explicit phone + // identity. The Apple family selector lets a watchOS UDID reach capability refusal. + flags: phoneFlags, androidAvdSelection: 'include-stopped', }); const admitted = await admitRuntimeUse({ @@ -285,6 +286,13 @@ export async function handleSessionStateCommands(params: { const result = await admitted.runtime.operations.pairWearable({ wearable: input.wearable, boot: input.boot, + ...(input.phone.platform === 'android' + ? { + androidSerialAllowlist: resolveAndroidSerialAllowlistForAppState( + req.flags?.androidDeviceAllowlist, + ), + } + : {}), }); return { ok: true, diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index cdce6ede7e..18d57d08ee 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -433,10 +433,9 @@ async function runWorldGuard(world: World): Promise { for (const command of driveOrder()) { for (const step of DRIVEN_COMMANDS[command]!(ctx)) { await ensureSession(daemon, world); + const options = step.input === undefined ? {} : { input: step.input }; const response = await withCommandTimeout( - daemon.callCommand(command, step.positionals ?? [], step.flags ?? {}, { - ...(step.input === undefined ? {} : { input: step.input }), - }), + daemon.callCommand(command, step.positionals ?? [], step.flags ?? {}, options), command, ); const out = { diff --git a/test/integration/smoke-web-platform.test.ts b/test/integration/smoke-web-platform.test.ts index 7100ea9e6b..f5a3284101 100644 --- a/test/integration/smoke-web-platform.test.ts +++ b/test/integration/smoke-web-platform.test.ts @@ -101,9 +101,25 @@ test('web shutdown cleanup reaps the exact daemon that survived graceful shutdow true, 'expected cleanup to escalate after the child ignored SIGTERM', ); + await waitForChildExit(child, 1_000); assert.equal(isProcessAlive(daemonPid), false); }); +async function waitForChildExit(child: ReturnType, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve, reject) => { + const onExit = () => { + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(() => { + child.off('exit', onExit); + reject(new Error(`Child process did not exit within ${timeoutMs}ms.`)); + }, timeoutMs); + child.once('exit', onExit); + }); +} + type StepRecord = { step: string; command: string;