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..32dd0ae240 100644 --- a/packages/contracts/src/client-device-view.ts +++ b/packages/contracts/src/client-device-view.ts @@ -6,7 +6,16 @@ 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'; +import type { + WearablePairingEndpoint, + WearablePairingStatus, + WearableSelector, +} from './wearable-pairing-runtime.ts'; export type AgentDeviceDevice = { platform: PublicPlatform; @@ -104,3 +113,17 @@ export type DeviceBootOptions = DeviceCommandBaseOptions & { }; export type DeviceShutdownOptions = DeviceCommandBaseOptions; + +export type PairWearableOptions = AgentDeviceRequestOverrides & { + phone: WearablePairingEndpoint; + wearable?: WearableSelector; + boot: boolean; +}; + +export type PairWearableResult = { + pairId: string; + phone: AgentDeviceDevice; + wearable: AgentDeviceDevice; + status: WearablePairingStatus; + 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..b85f8e9639 --- /dev/null +++ b/packages/contracts/src/wearable-pairing-runtime.ts @@ -0,0 +1,31 @@ +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 } | { 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'; + +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/kernel/src/device-selector-flags.test.ts b/packages/kernel/src/device-selector-flags.test.ts index 5b975d8dd0..c924a99a03 100644 --- a/packages/kernel/src/device-selector-flags.test.ts +++ b/packages/kernel/src/device-selector-flags.test.ts @@ -51,6 +51,22 @@ 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 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'); +}); + 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 5f435f138d..f94042860f 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; } @@ -477,14 +478,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/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 2a0e4c9f3b..c8b1b1dae6 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, @@ -220,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 @@ -240,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', @@ -424,6 +443,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, + pairWearable: androidWearablePairingFact(device), listApps: available, ...androidLifecycleFacts(device), shutdownTarget: device.kind === 'emulator' ? available : shutdownKindUnavailable, @@ -502,6 +522,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..42f8c8d077 --- /dev/null +++ b/packages/platform-android/src/wearable-pairing.test.ts @@ -0,0 +1,308 @@ +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 = { + 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 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, + { 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('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[]) => ({ + 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, + 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(), + ); +}); + +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 the phone id itself as the wearable even when another candidate is named Wear', async () => { + await expect( + pairAndroidWearable( + host({ discover: async () => [phone, watch] }), + phone, + { wearable: { deviceId: phone.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 }; + 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); +}); + +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(), + ); +}); + +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: PlatformRuntimeHost['deviceReadiness']['androidEmulator']['discover']; + 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..abdc7329b8 --- /dev/null +++ b/packages/platform-android/src/wearable-pairing.ts @@ -0,0 +1,265 @@ +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 { deviceShellArgv } from '@agent-device/kernel/device-shell'; +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, + input: PairWearableInput, + signal: AbortSignal, +): Promise { + let devices = await discover(host, signal, input.androidSerialAllowlist); + let wearable = await selectWearable(host, devices, phone, input, signal); + 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) { + 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, input.androidSerialAllowlist); + 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.'); + } + } + + 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.'); + } + const [characteristics, features] = await Promise.all([ + host.androidTools.runAdb( + wearable, + deviceShellArgv('adb', 'shell', ['getprop', 'ro.build.characteristics']), + { allowFailure: true, timeoutMs: 10_000 }, + signal, + ), + host.androidTools.runAdb( + wearable, + deviceShellArgv('adb', '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 { + 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, + androidSerialAllowlist?: readonly string[], +) { + return await host.deviceReadiness.androidEmulator.discover( + { + platform: 'android', + androidAvdSelection: 'include-stopped', + ...(androidSerialAllowlist ? { androidSerialAllowlist: [...androidSerialAllowlist] } : {}), + }, + signal, + ); +} + +async function selectWearable( + host: PlatformRuntimeHost, + devices: readonly DeviceInfo[], + phone: DeviceInfo, + input: PairWearableInput, + signal: AbortSignal, +): Promise { + 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), + ); +} + +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; + } + succeeded += 1; + if (/^feature:android\.hardware\.type\.watch\s*$/im.test(features)) matches.push(device); + } + 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: 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/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index f43bebedbb..c67b3ff326 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({ @@ -22,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' }, @@ -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 3e74b49add..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|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; @@ -35,6 +37,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 +60,17 @@ export function resolveAppleTargetFromLabel(value: string): DeviceTarget | null } export function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { + // 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/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.test.ts b/packages/platform-apple/src/runtime.test.ts index fdee94259d..ef2a32b8d5 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -123,6 +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(expectedPairWearableFact(device)); expect(facts.operations.listApps.available).toBe( device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', ); @@ -171,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/runtime.ts b/packages/platform-apple/src/runtime.ts index 3907f76689..218b778fc1 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); @@ -78,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', @@ -275,6 +285,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); @@ -333,6 +345,12 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, + pairWearable: + resolveDeviceAppleOs(device) === 'ios' || resolveDeviceAppleOs(device) === 'ipados' + ? device.kind === 'simulator' + ? available + : wearableDeviceKindUnavailable + : unavailable, listApps: apps, ...appleApplicationLifecycleFacts(device), shutdownTarget: shutdownFact(device), @@ -357,8 +375,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..04d31d4068 --- /dev/null +++ b/packages/platform-apple/src/wearable-pairing.test.ts @@ -0,0 +1,289 @@ +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).toContainEqual(['pair', phone.id, 'watch-1']); + 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); +}); + +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[] }) => { + 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); +}); + +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; +} + +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..ddc43beb14 --- /dev/null +++ b/packages/platform-apple/src/wearable-pairing.ts @@ -0,0 +1,292 @@ +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; +}; + +type WatchDeviceInfo = DeviceInfo & { simulatorState: 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 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 (input.boot && initialWatchState === 'Shutdown') { + bootedHere = true; + 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; + } 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(preRequestPairs, phone.id, wearable.id); + if (!pair) { + await runRequired( + host, + phone, + ['pair', phone.id, wearable.id], + signal, + 'CoreSimulator could not pair the selected phone and watch.', + ); + pair = findPair(await listPairs(host, phone, signal), phone.id, wearable.id); + } + if (!pair) { + throw new AppError( + 'COMMAND_FAILED', + 'CoreSimulator did not report the newly created wearable pair.', + ); + } + + if (!isPairActive(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: hasPairState(pair.state, 'connected') ? 'connected' : 'paired', + }; + } catch (error) { + await rollbackNewPairs(host, phone, preRequestPairIds, preRequestActivePairs); + if (bootedHere) { + await host.appleTools + .run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(phone, ['shutdown', wearable.id]), + allowFailure: true, + }, + undefined, + ) + .catch(() => undefined); + } + throw error; + } +} + +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() + .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): WatchDeviceInfo[] { + 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: WatchDeviceInfo[] = []; + 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', + simulatorState: entry.state ?? 'Unknown', + ...(simulatorSetPath ? { simulatorSetPath } : {}), + }); + } + } + return devices; +} + +function selectWatch(devices: WatchDeviceInfo[], input: PairWearableInput): WatchDeviceInfo { + 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.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 a68d22c991..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,6 +169,7 @@ export function limrunAppLogFacts( providerMode: 'provider-runtime', }, operations: { + pairWearable: wearablePairingUnavailable, appLogInspect: available, appLogDoctor: available, appLogStart: available, 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/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..cb50bc2916 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1167,12 +1167,7 @@ function createStubClient(params: { const command = createThrowingMethodGroup(); return { command, - devices: { - list: async () => [], - capabilities: unexpectedCommandCall, - boot: unexpectedCommandCall, - shutdown: unexpectedCommandCall, - }, + devices: createThrowingMethodGroup({ list: async () => [] }), sessions: { list: async () => [], stateDir: async () => '/tmp/agent-device-state', @@ -1279,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/__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..c70f5118a6 --- /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 identity and 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..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'; @@ -86,6 +78,8 @@ import type { CommandRequestResult, DeviceBootOptions, DeviceShutdownOptions, + PairWearableOptions, + PairWearableResult, DoctorCommandOptions, DragOptions, EventsOptions, @@ -196,6 +190,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/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/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 f8fe6eb39d..06552cb6cf 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -1,11 +1,13 @@ 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'; import { managementCliOutputFormatters } from './output.ts'; +import type { WearableSelector } from '@agent-device/contracts/wearable-pairing-runtime'; const devicesCommandMetadata = defineFieldCommandMetadata( 'devices', @@ -33,6 +35,40 @@ 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', + description: 'Phone endpoint containing platform and deviceId.', + properties: { + platform: { type: 'string', enum: ['ios', 'android'] }, + deviceId: { type: 'string' }, + }, + required: ['platform', 'deviceId'], + additionalProperties: false, + }), + ), + wearable: jsonSchemaField({ + type: 'object', + description: 'Optional wearable selector by deviceId, name, or both.', + properties: { + deviceId: { type: 'string', minLength: 1 }, + name: { type: 'string', minLength: 1 }, + }, + 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.')), + }, +); + const bootCliSchema = { allowedFlags: ['headless'], } as const satisfies CommandSchemaOverride; @@ -42,6 +78,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 +94,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 +154,41 @@ 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.', + ); + } + 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] }, + ...(positionals[1]?.trim() ? { wearable: { deviceId: positionals[1].trim() } } : {}), + 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/core/__tests__/device.test.ts b/src/core/__tests__/device.test.ts index 88379e7ab3..1ec9ed2acb 100644 --- a/src/core/__tests__/device.test.ts +++ b/src/core/__tests__/device.test.ts @@ -291,6 +291,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', diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index ca21dfbcc4..fda3be553f 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -419,6 +419,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..f4a5fa6313 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,265 @@ 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: vi.fn(async (_input) => ({ + pairId: 'pair-1', + phone, + wearable, + status: 'connected' as const, + })), + }, + [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 }, + wearable: { deviceId: wearable.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', + }, + }); + 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 () => { + 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.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 () => [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, deviceId: 'device-1' }, boot: false }, + }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-session-state-'), + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + }, +); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 1b095e3bb5..e8162bfb6f 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'; @@ -32,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 "" ).'; @@ -120,6 +122,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); @@ -240,6 +244,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; @@ -249,6 +255,57 @@ export async function handleSessionStateCommands(params: { }): Promise { const { req, sessionName, sessionStore } = 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, + // 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({ + 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, + ...(input.phone.platform === 'android' + ? { + androidSerialAllowlist: resolveAndroidSerialAllowlistForAppState( + req.flags?.androidDeviceAllowlist, + ), + } + : {}), + }); + 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 +468,68 @@ export async function handleSessionStateCommands(params: { return null; } +function readPairWearableInput(value: unknown): { + phone: { platform: 'ios' | 'android'; deviceId: string }; + wearable?: WearableSelector; + 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): 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.'); + } + if (deviceId) return { deviceId, ...(name ? { name } : {}) }; + return { 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..463adaeea7 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 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 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 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 non-Apple phone platforms at input validation', + 'Linux is rejected by the endpoint contract before device resolution', + ), + }, [C.shutdown]: { androidEmulator: androidEmulator.contract( ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, 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..18d57d08ee 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, @@ -48,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[]; @@ -72,6 +77,22 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.devices]: () => one(), [PUBLIC_COMMANDS.doctor]: () => one(), [PUBLIC_COMMANDS.boot]: () => one(), + [PUBLIC_COMMANDS.pairWearable]: () => [ + { + 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 }), [PUBLIC_COMMANDS.perf]: () => [{ positionals: [] }, { positionals: ['frames'] }], @@ -412,8 +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 ?? {}), + daemon.callCommand(command, step.positionals ?? [], step.flags ?? {}, options), command, ); const out = { @@ -470,6 +492,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 AppError && error.code === 'DEVICE_NOT_FOUND', + ); + } finally { + await daemon.close(); + } +}); + test('macOS Apple session never emits the internal apple platform on the wire', async () => { await runWorldGuard('macos'); }, 120_000); 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; diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d46a5df559..570446ca5e 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 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