From dc44d67a014b065ee8fcdaf83f924fcae0e94afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 10:02:02 +0200 Subject: [PATCH 1/2] feat: surface appleOs discriminant on public device output Additive, non-breaking: the public device/result shapes now carry the stored Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS) alongside the existing leaf platform (ios/macos). Consumers can distinguish Apple OSes on the wire instead of only the collapsed ios/macos leaves. - session-inventory devices/session_list stop stripping appleOs and emit it only for Apple devices (non-Apple omit it); platform stays the leaf. - boot/shutdown success results (contracts/device.ts) gain optional appleOs. - AgentDeviceDevice/AgentDeviceSessionDevice + client normalizers carry it. - appleOs values never equal the internal 'apple' token, so the apple-platform-output-guard is unaffected. Tests: devices projection per Apple fixture (ios/ipados/tvos/visionos/ macos) + non-Apple omission; client normalizer coverage. --- src/__tests__/client-normalizers.test.ts | 53 ++++++++++- src/client/client-normalizers.ts | 13 ++- src/client/client-types.ts | 12 +++ src/contracts/device.ts | 23 ++++- .../session-inventory-appleos.test.ts | 94 +++++++++++++++++++ src/daemon/handlers/__tests__/session.test.ts | 8 +- src/daemon/handlers/session-inventory.ts | 13 ++- src/daemon/handlers/session-state.ts | 4 + src/kernel/device.ts | 11 ++- 9 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-inventory-appleos.test.ts diff --git a/src/__tests__/client-normalizers.test.ts b/src/__tests__/client-normalizers.test.ts index 1e58ca16a6..dbfc5832d1 100644 --- a/src/__tests__/client-normalizers.test.ts +++ b/src/__tests__/client-normalizers.test.ts @@ -1,6 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { normalizeOpenDevice } from '../client/client-normalizers.ts'; +import { + normalizeDevice, + normalizeOpenDevice, + normalizeSession, +} from '../client/client-normalizers.ts'; import { PUBLIC_PLATFORMS } from '../kernel/device.ts'; test('normalizeOpenDevice accepts exactly the canonical leaf platforms', () => { @@ -33,6 +37,53 @@ test('normalizeOpenDevice rejects the apple selector and unknown platforms', () ); }); +test('normalizeDevice carries the additive appleOs discriminant when present', () => { + const ipad = normalizeDevice({ + platform: 'ios', + appleOs: 'ipados', + id: 'ipad-sim-1', + name: 'iPad Pro 11-inch', + kind: 'simulator', + booted: true, + }); + assert.equal(ipad.appleOs, 'ipados'); + // `platform` stays the PUBLIC leaf; appleOs is additive, not a replacement. + assert.equal(ipad.platform, 'ios'); +}); + +test('normalizeDevice omits appleOs for non-Apple and invalid values', () => { + const android = normalizeDevice({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + }); + assert.equal('appleOs' in android, false); + + const bogus = normalizeDevice({ + platform: 'ios', + appleOs: 'windowsphone', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + }); + assert.equal('appleOs' in bogus, false); +}); + +test('normalizeSession carries the additive appleOs discriminant on the session device', () => { + const session = normalizeSession({ + name: 'default', + createdAt: 1, + platform: 'ios', + appleOs: 'tvos', + id: 'tv-sim-1', + device: 'Apple TV', + target: 'tv', + }); + assert.equal(session.device.appleOs, 'tvos'); + assert.equal(session.device.platform, 'ios'); +}); + test('normalizeOpenDevice preserves per-platform identifier shaping', () => { const ios = normalizeOpenDevice({ platform: 'ios', diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index ad0a866fb7..1649aef4e3 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -4,7 +4,7 @@ import type { DaemonRequest, SessionRuntimeHints } from '../daemon/types.ts'; import { AppError, type NormalizedError } from '../kernel/errors.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; -import { isPublicPlatform } from '../kernel/device.ts'; +import { isAppleOs, isPublicPlatform, type AppleOS } from '../kernel/device.ts'; import { leaseScopeFromOptions, leaseScopeToCommandFlags, @@ -94,6 +94,7 @@ export function normalizeMaterializationReleaseResult( export function normalizeDevice(value: unknown): AgentDeviceDevice { const { record, platform, id, name, target } = readClientDeviceIdentity(value, 'name'); + const appleOs = readAppleOs(record); return { platform, target, @@ -101,6 +102,8 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice { id, name, booted: typeof record.booted === 'boolean' ? record.booted : undefined, + // Additive Apple-OS discriminant; present only when the daemon emits it (Apple devices). + ...(appleOs ? { appleOs } : {}), identifiers: buildDeviceIdentifiers(platform, id, name), ...buildClientDevicePlatformFields(platform, id), }; @@ -109,6 +112,7 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice { export function normalizeSession(value: unknown): AgentDeviceSession { const { record, platform, id, name, target } = readClientDeviceIdentity(value, 'name'); const deviceName = readRequiredString(record, 'device'); + const appleOs = readAppleOs(record); const identifiers = { session: name, ...buildDeviceIdentifiers(platform, id, deviceName), @@ -123,6 +127,8 @@ export function normalizeSession(value: unknown): AgentDeviceSession { target, id, name: deviceName, + // Additive Apple-OS discriminant; present only when the daemon emits it (Apple devices). + ...(appleOs ? { appleOs } : {}), identifiers, ...buildClientDevicePlatformFields( platform, @@ -134,6 +140,11 @@ export function normalizeSession(value: unknown): AgentDeviceSession { }; } +function readAppleOs(record: Record): AppleOS | undefined { + const value = record.appleOs; + return isAppleOs(value) ? value : undefined; +} + function readClientDeviceIdentity(value: unknown, nameField: string) { const record = asRecord(value); return { diff --git a/src/client/client-types.ts b/src/client/client-types.ts index abfd33cb37..c77b2ecc7b 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -13,6 +13,7 @@ import type { SessionRuntimeHints, } from '../kernel/contracts.ts'; import type { + AppleOS, DeviceKind, DeviceTarget, PublicPlatform, @@ -61,6 +62,7 @@ export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion- export type { AppsFilter } from '../contracts/app-inventory.ts'; export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from '../alert-contract.ts'; export type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts'; +export type { AppleOS } from '../kernel/device.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../contracts/device.ts'; export type { ViewportCommandResult } from '../contracts/viewport.ts'; export type { @@ -162,6 +164,11 @@ export type AgentDeviceDevice = { id: string; name: string; booted?: boolean; + /** + * Additive Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS). Present only for + * Apple devices; `platform` still carries the leaf (`ios`/`macos`). + */ + appleOs?: AppleOS; identifiers: AgentDeviceIdentifiers; ios?: { udid: string; @@ -176,6 +183,11 @@ export type AgentDeviceSessionDevice = { target: DeviceTarget; id: string; name: string; + /** + * Additive Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS). Present only for + * Apple devices; `platform` still carries the leaf (`ios`/`macos`). + */ + appleOs?: AppleOS; identifiers: AgentDeviceIdentifiers; ios?: { udid: string; diff --git a/src/contracts/device.ts b/src/contracts/device.ts index c1be161e25..9011a01dd4 100644 --- a/src/contracts/device.ts +++ b/src/contracts/device.ts @@ -1,11 +1,11 @@ -import type { DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts'; +import type { AppleOS, DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts'; import type { TargetShutdownResult } from '../target-shutdown-contract.ts'; /** * Closed result of the `boot` command. Mirrors the daemon handler's only * success return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed - * object literal `{ platform, target, device, id, kind, booted }`. The handler - * spreads nothing, so this shape is intentionally closed. + * object literal `{ platform, target, device, id, kind, booted }` plus the + * additive `appleOs` discriminant, emitted only for Apple devices. */ export type BootCommandResult = { platform: PublicPlatform; @@ -17,13 +17,20 @@ export type BootCommandResult = { kind: DeviceKind; /** Always `true` on the success path. */ booted: true; + /** + * Additive Apple-OS discriminant (`device.appleOs`): iPhone/iPad/tvOS/visionOS/macOS. + * Present only for Apple devices; absent for non-Apple platforms. `platform` stays the + * leaf (`ios`/`macos`) — this is an extra field, not a replacement. + */ + appleOs?: AppleOS; }; /** * Closed result of the `shutdown` command. Mirrors the daemon handler's success * return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed object - * literal `{ platform, target, device, id, kind, shutdown }`. The `shutdown` - * field is the raw {@link TargetShutdownResult} from `shutdownDeviceTarget`. + * literal `{ platform, target, device, id, kind, shutdown }` plus the additive + * `appleOs` discriminant (Apple devices only). The `shutdown` field is the raw + * {@link TargetShutdownResult} from `shutdownDeviceTarget`. */ export type ShutdownCommandResult = { platform: PublicPlatform; @@ -34,4 +41,10 @@ export type ShutdownCommandResult = { id: string; kind: DeviceKind; shutdown: TargetShutdownResult; + /** + * Additive Apple-OS discriminant (`device.appleOs`): iPhone/iPad/tvOS/visionOS/macOS. + * Present only for Apple devices; absent for non-Apple platforms. `platform` stays the + * leaf (`ios`/`macos`) — this is an extra field, not a replacement. + */ + appleOs?: AppleOS; }; diff --git a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts new file mode 100644 index 0000000000..0e7976d2c6 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts @@ -0,0 +1,94 @@ +import { test, expect, vi, beforeEach } from 'vitest'; + +// The `devices` handler resolves its inventory through listDeviceInventory; mocking it +// lets us drive the additive `appleOs` projection off the shared device fixtures without +// touching real local discovery. +vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listDeviceInventory: vi.fn(async () => []) }; +}); + +import { handleSessionInventoryCommands } from '../session-inventory.ts'; +import { listDeviceInventory } from '../../../core/dispatch-resolve.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import type { AppleOS, DeviceInfo } from '../../../kernel/device.ts'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + IPADOS_SIMULATOR, + MACOS_DEVICE, + TVOS_SIMULATOR, + VISIONOS_SIMULATOR, +} from '../../../__tests__/test-utils/device-fixtures.ts'; + +const mockListDeviceInventory = vi.mocked(listDeviceInventory); + +beforeEach(() => { + mockListDeviceInventory.mockReset(); +}); + +type PublicDevice = { id: string; platform: string; appleOs?: AppleOS }; + +async function runDevices(): Promise { + const req: DaemonRequest = { + token: 't', + session: 'default', + command: 'devices', + positionals: [], + flags: {}, + }; + return handleSessionInventoryCommands({ + req, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-inventory-appleos-'), + }); +} + +async function listPublicDevices(inventory: DeviceInfo[]): Promise { + mockListDeviceInventory.mockResolvedValue(inventory); + const response = await runDevices(); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('expected devices to succeed'); + return response.data?.devices as PublicDevice[]; +} + +test('devices surfaces the appleOs discriminant per Apple fixture', async () => { + const devices = await listPublicDevices([ + IOS_SIMULATOR, + IPADOS_SIMULATOR, + TVOS_SIMULATOR, + VISIONOS_SIMULATOR, + MACOS_DEVICE, + ]); + + const byId = new Map(devices.map((device) => [device.id, device])); + const expected: Array<[string, AppleOS, string]> = [ + [IOS_SIMULATOR.id, 'ios', 'ios'], + [IPADOS_SIMULATOR.id, 'ipados', 'ios'], + [TVOS_SIMULATOR.id, 'tvos', 'ios'], + [VISIONOS_SIMULATOR.id, 'visionos', 'ios'], + [MACOS_DEVICE.id, 'macos', 'macos'], + ]; + + for (const [id, appleOs, leaf] of expected) { + const device = byId.get(id); + expect(device, `expected device ${id} in output`).toBeTruthy(); + // The additive `appleOs` carries the specific Apple OS ... + expect(device?.appleOs).toBe(appleOs); + // ... while `platform` stays the PUBLIC leaf (never the internal `apple`). + expect(device?.platform).toBe(leaf); + expect(device?.platform).not.toBe('apple'); + } +}); + +test('devices omits appleOs for non-Apple devices', async () => { + const devices = await listPublicDevices([ANDROID_EMULATOR, IOS_SIMULATOR]); + + const android = devices.find((device) => device.id === ANDROID_EMULATOR.id); + expect(android?.platform).toBe('android'); + expect(android && 'appleOs' in android).toBe(false); + + const ios = devices.find((device) => device.id === IOS_SIMULATOR.id); + expect(ios?.appleOs).toBe('ios'); +}); diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 6b12fe3935..8e708c606c 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -318,7 +318,7 @@ test('devices filters Apple-family platform selectors', async () => { } }); -test('devices omits internal appleOs from the public inventory projection', async () => { +test('devices surfaces appleOs additively while keeping platform the public leaf', async () => { const sessionStore = makeSessionStore(); mockListAndroidDevices.mockResolvedValue([]); mockListAppleDevices.mockResolvedValue([ @@ -352,7 +352,11 @@ test('devices omits internal appleOs from the public inventory projection', asyn if (response?.ok) { const devices = response.data?.devices as Array> | undefined; expect(devices).toHaveLength(1); - expect(devices?.[0]).not.toHaveProperty('appleOs'); + // appleOs is now surfaced additively (iPad -> ipados) ... + expect(devices?.[0]?.appleOs).toBe('ipados'); + // ... while `platform` stays the PUBLIC leaf (never the internal `apple`). + expect(devices?.[0]?.platform).toBe('ios'); + // The internal-only simulator set path is still stripped from the public shape. expect(devices?.[0]).not.toHaveProperty('simulatorSetPath'); expect(devices?.[0]?.id).toBe('sim-1'); } diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 3ad434559f..8266f3e63e 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -45,6 +45,8 @@ export async function handleSessionInventoryCommands(params: { runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), // approach (b): emit the PUBLIC leaf platform (ios/macos), not `apple`. platform: publicPlatformString(session.device), + // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). + ...(session.device.appleOs ? { appleOs: session.device.appleOs } : {}), target: session.device.target ?? 'mobile', surface: session.surface ?? 'app', device: session.device.name, @@ -92,14 +94,17 @@ export async function handleSessionInventoryCommands(params: { const filtered = req.flags?.target ? platformFiltered.filter((device) => (device.target ?? 'mobile') === req.flags?.target) : platformFiltered; - // Keep appleOs internal-only for now: it is discovery groundwork and the - // public `devices` shape is not yet meant to expose it. Surfacing it (so - // agents can tell iPad from iPhone) should be a deliberate later change. - // approach (b): project `platform` back to the PUBLIC leaf (ios/macos). + // Surface the `appleOs` discriminant additively so consumers can distinguish + // iPhone/iPad/tvOS/visionOS/macOS instead of only the leaf `ios`/`macos`. It is + // emitted ONLY for Apple devices (non-Apple platforms carry no `appleOs`), and + // `platform` stays the PUBLIC leaf via `publicPlatformString` (approach b). The + // internal-only `simulatorSetPath` is still stripped. `appleOs` values never equal + // the internal `apple` token, so this does not affect the apple-leak guard. const publicDevices = filtered.map( ({ simulatorSetPath: _simulatorSetPath, appleOs, ...device }) => ({ ...device, platform: publicPlatformString({ platform: device.platform, appleOs }), + ...(appleOs ? { appleOs } : {}), }), ); return { ok: true, data: { devices: publicDevices } }; diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index c7f1959fa2..3a501e4ad3 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -277,6 +277,8 @@ export async function handleSessionStateCommands(params: { id: device.id, kind: device.kind, booted: true, + // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). + ...(device.appleOs ? { appleOs: device.appleOs } : {}), }, }; } @@ -342,6 +344,8 @@ export async function handleSessionStateCommands(params: { id: device.id, kind: device.kind, shutdown, + // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). + ...(device.appleOs ? { appleOs: device.appleOs } : {}), }, }; } diff --git a/src/kernel/device.ts b/src/kernel/device.ts index faa2fbcab7..cb29835d39 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -8,7 +8,8 @@ export type ApplePlatform = 'ios' | 'macos'; // Explicit, stored Apple operating system. All six literals are reserved so the // type is stable as platform support grows, but discovery only ever populates // the four currently supported ones ('ios' | 'ipados' | 'tvos' | 'macos'). -export type AppleOS = 'ios' | 'ipados' | 'tvos' | 'watchos' | 'visionos' | 'macos'; +const APPLE_OS_VALUES = ['ios', 'ipados', 'tvos', 'watchos', 'visionos', 'macos'] as const; +export type AppleOS = (typeof APPLE_OS_VALUES)[number]; // Internal device platforms. Apple OSes collapse to a single `apple` platform; the // `appleOs` field on DeviceInfo is the sole OS discriminant. export const PLATFORMS = ['apple', 'android', 'linux', 'web'] as const; @@ -142,6 +143,14 @@ export function isPublicPlatform(value: unknown): value is PublicPlatform { return (PUBLIC_PLATFORMS as readonly unknown[]).includes(value); } +export function isAppleOs(value: unknown): value is AppleOS { + // The stored Apple-OS discriminant carried additively on the PUBLIC device output + // (iPhone/iPad/tvOS/visionOS/macOS). Used by the client normalizers to validate the + // optional `appleOs` field parsed from a daemon response. Its values never include the + // internal `apple` platform token, so surfacing it does not affect the apple-leak guard. + return (APPLE_OS_VALUES as readonly unknown[]).includes(value); +} + export function matchesPlatformSelector( device: Pick, selector: PlatformSelector | undefined, From 33e4f1c8fdc2dfc132df562e3ade2fc39066915f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 10:53:03 +0200 Subject: [PATCH 2/2] fix: gate public appleOs surfacing to Apple platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appleOs is Apple-only, but the daemon projection (devices, session_list, boot, shutdown) and the client normalizers preserved it whenever the field was present — so a malformed/legacy non-Apple record carrying a valid Apple OS value would leak it on the public wire. Gate every emission site on isApplePlatform(platform), not just field presence. Regression tests: a non-Apple device carrying a stray appleOs='macos' drops it in both the daemon devices projection and normalizeDevice. --- src/__tests__/client-normalizers.test.ts | 12 ++++++++++++ src/client/client-normalizers.ts | 7 ++++--- .../__tests__/session-inventory-appleos.test.ts | 11 +++++++++++ src/daemon/handlers/session-inventory.ts | 10 +++++++--- src/daemon/handlers/session-state.ts | 12 ++++++++---- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/__tests__/client-normalizers.test.ts b/src/__tests__/client-normalizers.test.ts index dbfc5832d1..f41a404f36 100644 --- a/src/__tests__/client-normalizers.test.ts +++ b/src/__tests__/client-normalizers.test.ts @@ -68,6 +68,18 @@ test('normalizeDevice omits appleOs for non-Apple and invalid values', () => { kind: 'simulator', }); assert.equal('appleOs' in bogus, false); + + // Regression: a non-Apple platform carrying a VALID Apple OS value must still be + // dropped — appleOs is Apple-only, gated on the platform, not merely on being a + // valid AppleOS value. + const androidWithStrayAppleOs = normalizeDevice({ + platform: 'android', + appleOs: 'macos', + id: 'emulator-5555', + name: 'Pixel', + kind: 'emulator', + }); + assert.equal('appleOs' in androidWithStrayAppleOs, false); }); test('normalizeSession carries the additive appleOs discriminant on the session device', () => { diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index 1649aef4e3..f5608933a1 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -4,7 +4,7 @@ import type { DaemonRequest, SessionRuntimeHints } from '../daemon/types.ts'; import { AppError, type NormalizedError } from '../kernel/errors.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; -import { isAppleOs, isPublicPlatform, type AppleOS } from '../kernel/device.ts'; +import { isAppleOs, isApplePlatform, isPublicPlatform, type AppleOS } from '../kernel/device.ts'; import { leaseScopeFromOptions, leaseScopeToCommandFlags, @@ -102,8 +102,9 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice { id, name, booted: typeof record.booted === 'boolean' ? record.booted : undefined, - // Additive Apple-OS discriminant; present only when the daemon emits it (Apple devices). - ...(appleOs ? { appleOs } : {}), + // Additive Apple-OS discriminant; Apple platforms only — gate on the platform so + // a non-Apple record with a stray appleOs value is not preserved. + ...(isApplePlatform(platform) && appleOs ? { appleOs } : {}), identifiers: buildDeviceIdentifiers(platform, id, name), ...buildClientDevicePlatformFields(platform, id), }; diff --git a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts index 0e7976d2c6..7e308a8c92 100644 --- a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts @@ -92,3 +92,14 @@ test('devices omits appleOs for non-Apple devices', async () => { const ios = devices.find((device) => device.id === IOS_SIMULATOR.id); expect(ios?.appleOs).toBe('ios'); }); + +test('devices drops a stray appleOs on a non-Apple device (gated to Apple platforms)', async () => { + // Regression: appleOs is Apple-only. A malformed/legacy NON-Apple record carrying a + // valid Apple OS value must NOT surface it — the projection gates on the platform, + // not merely on field presence. + const androidWithStrayAppleOs: DeviceInfo = { ...ANDROID_EMULATOR, appleOs: 'macos' }; + const devices = await listPublicDevices([androidWithStrayAppleOs]); + const android = devices.find((device) => device.id === ANDROID_EMULATOR.id); + expect(android?.platform).toBe('android'); + expect(android && 'appleOs' in android).toBe(false); +}); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 8266f3e63e..6345542c80 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -45,8 +45,12 @@ export async function handleSessionInventoryCommands(params: { runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), // approach (b): emit the PUBLIC leaf platform (ios/macos), not `apple`. platform: publicPlatformString(session.device), - // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). - ...(session.device.appleOs ? { appleOs: session.device.appleOs } : {}), + // Additive Apple-OS discriminant; Apple devices only. Gate on the + // platform (not just field presence) so a non-Apple record carrying a + // stray appleOs value never surfaces it. + ...(isApplePlatform(session.device.platform) && session.device.appleOs + ? { appleOs: session.device.appleOs } + : {}), target: session.device.target ?? 'mobile', surface: session.surface ?? 'app', device: session.device.name, @@ -104,7 +108,7 @@ export async function handleSessionInventoryCommands(params: { ({ simulatorSetPath: _simulatorSetPath, appleOs, ...device }) => ({ ...device, platform: publicPlatformString({ platform: device.platform, appleOs }), - ...(appleOs ? { appleOs } : {}), + ...(isApplePlatform(device.platform) && appleOs ? { appleOs } : {}), }), ); return { ok: true, data: { devices: publicDevices } }; diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 3a501e4ad3..f9f830d059 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -277,8 +277,10 @@ export async function handleSessionStateCommands(params: { id: device.id, kind: device.kind, booted: true, - // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). - ...(device.appleOs ? { appleOs: device.appleOs } : {}), + // Additive Apple-OS discriminant; Apple devices only. Gate on the platform + // (not just field presence) so a non-Apple record with a stray appleOs never + // surfaces it. + ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), }, }; } @@ -344,8 +346,10 @@ export async function handleSessionStateCommands(params: { id: device.id, kind: device.kind, shutdown, - // Additive Apple-OS discriminant; Apple devices only (non-Apple omit it). - ...(device.appleOs ? { appleOs: device.appleOs } : {}), + // Additive Apple-OS discriminant; Apple devices only. Gate on the platform + // (not just field presence) so a non-Apple record with a stray appleOs never + // surfaces it. + ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), }, }; }