watchOS as its own platform, fail-closed per tool¶
Status: accepted for #14899 (part of the Apple Watch simulator epic #14895); extended to every non-iOS Apple platform for #15886 (Apple TV epic #15884).
Decision¶
An Apple Watch simulator is platform: "watchos", a third member of Platform
(src/models/Platform.ts), not an iOS device with a family flag. The same rule covers every
non-iOS Apple platform (NonIosApplePlatform): an Apple TV simulator is platform: "tvos", and
everything below that says “watch” applies to it unless a section names watchOS specifically.
tvos supports listDevices, listDeviceImages, provisionDevice, deleteDevice,
startDevice, setActiveDevice, killDevice, installApp, uninstallApp, launchApp,
terminateApp, listApps, observe, tapOn and pressButton. Discovery and selection require an explicit
tvOS request. Boot uses simctl readiness and the simulator gate; start binds at booted
readiness after best-effort tvOS runner startup, without iOS keyboard, biometric or prototype setup.
The tvOS runner uses request_hierarchy through the iOS hierarchy converter, preserving focus
in the hierarchy and existing focusedElement output. pressButton sends remote up/down/left/right,
select, menu, home and playPause. tapOn matches visible exact text or element identifiers
with an optional index, moves focus using frame centres and re-observes after each remote
press before selecting. It accepts only the tap action; absent matches or focus, blocked
directions and a 30-move limit fail with actionable guidance. Press replies synchronize
navigation without sleeps. Touch gestures are refused with focus-navigation guidance;
long press and text input are unsupported. A tvOS handshake is required before registration.
AUTOMOBILE_TVOS_RUNNER_XCTESTRUN overrides artifact discovery; otherwise an appletvsimulator
xctestrun is discovered or the AutoMobileTVTest scheme is built with a 120-second limit.
Startup uses the tvOS destination and an isolated xctestrun with AM_PORT; runner ownership,
release, shutdown and deletion follow the watch lifecycle. Connected observations use runner
screenshots, while observations without a runner retain the simctl screenshot-only fallback. A broker binding
never reuses a TV. Unlike watchOS,
tvos is advertised in tool input schemas ahead of support (see the schema rule below).
- Fail closed at the tool boundary. Every device-aware tool rejects a watch or an Apple TV with
an
ActionableErrornaming the platform (<tool> does not support watchOS yet,<tool> does not support tvOS yet) after device resolution and before navigation recording, mutation journaling, audits or the handler run. The gate is in the registry’s shared device dispatch, so a newly registered tool is covered without any change. - One allowlist map.
APPLE_PLATFORM_SUPPORTED_TOOLS(src/server/applePlatformToolSupport.ts) maps each non-iOS Apple platform to the tools that opted in for it. A tool joins a platform only together with that platform’s behaviour and tests.test/server/applePlatformToolSupport.test.tsregisters every tool and, for each platform, drives each device-aware tool not on that platform’s list through the registry with a device of that platform, asserting the rejection. - Platform branches decide explicitly. Code that dispatches on platform narrows with
requireAndroidOrIosPlatform(platform, operation)(throws for a watch) orisAndroidOrIosPlatform(skips a watch in background loops). A watch never falls through to an Androidelseor an iPhone branch. Maps from platform to an implementation are keyed byAndroidOrIosPlatform. Bookkeeping that only records a device’s platform (bindings, reservations, sessions) holds the fullPlatform. - Schemas advertise watchOS only where it works; tvOS everywhere. A tool’s input
platformenum listswatchosonly when the tool supports a watch, so agents are not offered a watch value the tool rejects; such a tool gets its own enum. Every inputplatformenum liststvos, including the sharedplatformSchema(owner decision 2026-10-31, #15886), so clients can name an Apple TV explicitly. Device-aware tools refuse a requested non-iOS Apple platform they have not opted in to before resolving a target (assertRequestedPlatformSupported), because resolution replaces the requested platform with a session’s own, and refuse an unsupported resolved platform at the registry gate. Plain tools look up tvOS targets through their own platform-specific paths; unsupported plain tools refuse tvOS before lookup (assertRequestNotTvos).provisionDeviceuses a per-platform union with platform-specific spec fields, including a tvOS branch. Output schemas listtvosonly where they already listwatchos. - Allowlisted app tools.
installApp,uninstallApp,listApps,launchAppandterminateApproute a watch toWatchAppManager(simctl install|uninstall|listapps|launch| terminateon the watch UDID) through their executor factories; Android and iOS keep their executors.installApptakes only a.app(bundle id from Info.plist;upgradefrom a prior listing). Android-only options are refused by name;uninstallApprefuseskeepData: true.launchAppreports a not-installed app without launching;coldBootstops a running process first;clearAppDatareuses the iOS simulator wipe (terminate, resolve the data container confined to the watch’s device-set directory, remove Documents, Library and tmp), and a failed wipe launches nothing.prototype: trueis refused before any agent preparation. AterminateAppwhose process is already gone is a success withwasRunning: false. - Runner-backed watch observation and actions.
observeretains a simctl PNG. When an explicitly registered watch runner is connected, it also returns the hierarchy normalized through the iOS observation model; bounds andscreenSizeuse XCTest points whilescreenshotImageSizeuses physical pixels. Without a runner it returns the screenshot, physicalscreenSizeandhierarchyUnavailable, with hierarchy fields absent. Advanced observe options (waitFor,settled,raw,includeExtras,scope,layer,skipBackStack,crop,screenshotOptions,screenshot: "none", another display) are refused.projectuses the existing full/skeleton rendering when connected and is refused otherwise.tapOnaccepts an exact identifier or text, with optional index; an identifier is sent directly, otherwise the node center is tapped. Only wholly on-screen nodes qualify.tapAtaccepts absolute XCTest points.swipeOnuses directional runner swipes and optionally searches for exact text/identifier, bounded bymaxSwipes(default 10, maximum 100). Runner swipes have no host-controlled coordinates or duration.pressButtonaccepts home only. Other targeting, timed gestures, long press, double tap, focus, pinch, multi-touch and text input fail before runner commands. - Explicit runner ownership.
XcodebuildWatchRunnerLaunchertakes a prebuilt xctestrun, watch UDID and reserved localhost port; it launches only the watch service test, forwardsTEST_RUNNER_AM_PORT, and registers the device after a successful watchOS handshake and hierarchy read. Startup retries connection/readiness for a bounded budget. Process exit or the returned stop handle closes the client and removes the registration. After boot, before session binding,startDevicediscovers a watchsimulator xctestrun in the configured CtrlProxy derived data, building theCtrlProxyWatchscheme withbuild-for-testingif absent.AUTOMOBILE_WATCH_RUNNER_XCTESTRUNselects an explicit prebuilt artifact. A per-launch copy injectsAM_PORTfor the allocated localhost port. The lifecycle owner reuses a registered runner and attaches its own runner to the newly bound session. It stops its own handle on device stop/deletion, session release/unbinding and daemon shutdown/exit. A failed or cancelled bind stops only a runner launched by that call. Missing artifacts or failed startup warn and leave readiness atbooted, with the reason in the response message and screenshot-only observation available. The WebSocket client correlates request IDs and bounds handshake plus command time with the injectable timer, refusing non-watchOS handshakes. - Explicit resolution. A watch session resolves through the pooled-session path (readiness
setup and keep-awake are Android-only). A sessionless call reaches a watch only with
platform: "watchos": readiness then resolves a watch a fresh watchOS listing shows booted (by deviceId, else the connection’s watch pin, else the only booted watch) and runs no verification, boot, CtrlProxy or settings step. A watch pin never decides a platform-less call.setActiveDeviceselects a pooled watch only when the call names"watchos". - Plain tools that look up a device themselves (device management such as
killDevice) are not behind the registry gate. They reach a watch only through watch inventory, so each decides watches explicitly.startDevicewithplatform: "watchos"takes an exact UDID or exact name only (every other option is refused), boots or adopts the watch under its stable lifecycle lease and prepares its automation runner before binding a pooled session at readinessbooted.killDevicetakes a watch through the same holder checks and stops it withsimctl shutdown. Both resolve the target from the watch listing, so a non-watch UDID is refused before any simctl command.provisionDevicewithdevice.platform: "watchos"(its own branch of the request union, so Android and iOS validation is unchanged) takes exact runtime and device-type identifiers from the watch catalog. Under a lifecycle lease on the name, rebound to the UDID once known, it adopts a watch whose name, UDID, device type and runtime all match, or creates one fresh withsimctl create(never cloned; a named UDID is never created). Withbootit boots and binds likestartDevice, and a watch it created is deleted again if boot or bind fails.resources,enableToolsandreadiness: "automation"are refused; readiness defaults to"none".deleteDevicewithtarget.platform: "watchos"resolves the watch by UDID from the watch listing (absent means already deleted), applies killDevice’s caller checks under thedeleteDevicename, stops a running watch through killDevice before taking its teardown lease (killDevice’s own lease would otherwise wait on it), then runssimctl deleteand requires a fresh watch listing that no longer shows it. - Shared limits. A watch is charged wherever a host limit counts devices: the broker’s
managed-device limit (
totalDeviceCap) countswatchosbindings and running daemon-named watches in one total with Android and iOS devices, and a cold watch boot is admitted through the simulator boot gate. - Broker watch bindings. A
watchosbinding holds one unpaired watch (neversimctl pair, no companion iPhone). Its requested spec is provisionDevice’s watch spec: exactruntimeanddeviceType, nothing else. The resolver checks it against the watch catalog before any native work (an unlisted runtime or device type isspec_unsupportednaming the listed ones; a listed but unavailable runtime isruntime_incompatible; a host without simctl refuses it). The matcher compares runtime and device type only, never an AVD config or iOS form-factor inference. The reconciler creates anambroker-watch through provisionDevice’s watch path (freshsimctl create, never cloned), boots it through the simulator gate (a replacement reserves its slot against the old watch’s UDID), and publishes it at readinessbooted(provisionDevice’snone/device_ready): the automation-readiness gate accepts that forwatchosonly. Between executions a watch follows the iOS binding policy: the release keeps it assigned and booted, and the next execution ends any leftover session and reuses it with a fresh one. A spec change replaces the watch; replacement, eviction and abandoned-binding cleanup delete it through the daemon’s deleteDevice watch path (stop,simctl delete, then a fresh watch listing must not show it), and a failed delete leaves the bindingcleanup_pending. Legacy slot rows never held watches, so legacy slot retirement refuses a watch target. - Session-broker watch requests. A session request names a watch exactly:
{ schemaVersion: 1, platform: "watchos", spec: { runtime, deviceType } }, the spec parsed by provisionDevice’s watch schema. There are no host defaults, archetypes or OS predicates. Before anything is reserved, the request is checked against the session watch catalog (installed watchOS runtimes and Apple Watch device types fromWatchSimulatorInventory.readCatalog): an unlisted device type isunsupported_hardware, an unlisted or unavailable runtime ismissing_installed_runtime, each naming the available ones; a host without simctl reports the catalognot-installed(permanentunsupported_hardware), and an unreadable catalog is transientcatalog_unavailable. The exact profile (platform,metadataState,runtime,deviceType) becomes the watch binding’s requested spec, and a pooled watch is reused only for the same runtime and device type. A ready watch receipt addsreadiness: { mode: "none", status: "device_ready" }; Android and iOS receipts carry noreadinessfield. A watch session binds no device-mutation recorder. - Watch release resets follow the iOS reuse mode.
reuse.ios.mode(andreuseCleanDevices) decide a released watch’s fate as they do an iPhone’s; the iOS idle TTL, reset-boot queue and wait-for-a-resetting-device admission apply to it too.off: destroyed.erase: the watch is stopped through killDevice,simctl erased while shut down, booted again through the simulator boot gate and confirmed Booted with its exact runtime and device type; a redrive that finds it shut down erases it again.keep-booted: the iOS cleaning replays the session’s mutation journal against a pool-entry app baseline and probes CtrlProxy health; a watch has none of these, so that step is skipped withfallbackReasonwatchos-keep-booted-unsupportedand the configured fallback runs (erase: the watch erase, outcomefallback:erase;destroy: destroyed).minimal: its steps need the session’s journaled app installs, and its appearance, content-size, pasteboard and Files-fixture steps have no watchOS equivalent; minimal never erases, so the watch is destroyed withfallbackReasonwatchos-minimal-unsupported. No pool-entry baseline is captured for a watch. "either"keeps meaning Android or iOS. Listingscannedflags carry awatchosentry; until watch inventory exists it isfalse, so a watch is never treated as a freshly listed device.
Alternatives checked¶
| Alternative | Why not |
|---|---|
platform: "ios" plus deviceFamily: "watch" |
Every existing platform === "ios" branch would accept a watch unless someone remembers to check the family. That is the failure the physical device lister already guards against. |
| Gate each tool in its own handler | A forgotten tool would silently drive a watch as an iPhone. One gate in the registry covers new tools by default. |
Add "watchos" to every tool schema enum now |
Advertises a value every tool rejects. Schemas widen per tool when it opts in. |