From 5738d3918a7dfce633e6d30f51fb03bdceb94cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 10 Sep 2026 16:17:55 +0200 Subject: [PATCH 1/2] refactor(contracts): make clipboard capability facts additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive capability facts on the clipboard family (#2443, family 3). clipboardRuntimeOperationFacts took exactly two cells, read and write, and both are required. The halves stay separate claims — a WebDriver provider whose Appium clipboard extension exposes only a getter is a real owner with one half and not the other, and `clipboard read` must not be refused because the write half is missing — so both are optional and `unsupported` names the denial an unnamed half reports. A call naming no denial is still refused: omission is a classified refusal, never an unclassified half and never an implied success. Six of the eight owners drive both halves from one shell command set or one leaf gate, so they now state that once: web, HarmonyOS, and Vega name a single denial, and the shared unavailable record carries one clipboard cell where it carried two. The owners that serve clipboard keep naming both halves, and the one install-source double that enumerated the two keys by hand now goes through the family builder like every other construction site. Fact values are unchanged: every owner's clipboard cells are byte-identical, and the family's own test pins an unnamed half reporting the stated denial verbatim, an owner naming nothing answering with the exhaustive shape, and the freeze. --- .../contracts/src/clipboard-runtime.test.ts | 37 ++++++++++++++++++- packages/contracts/src/clipboard-runtime.ts | 30 ++++++++++++--- .../src/platform-runtime-unavailable.test.ts | 3 +- .../src/platform-runtime-unavailable.ts | 11 ++---- packages/platform-android/src/runtime.ts | 6 ++- packages/platform-apple/src/system/runtime.ts | 19 +++++++++- packages/platform-harmonyos/src/runtime.ts | 5 +-- packages/platform-linux/src/runtime.ts | 4 +- packages/platform-vega/src/runtime.ts | 3 +- packages/platform-web/src/runtime.ts | 5 +-- .../provider-limrun/src/app-log-runtime.ts | 3 +- .../src/interaction-operations.ts | 25 +++++++++++-- .../src/platform-runtime.ts | 7 ++-- .../test-utils/runtime-operation-facts.ts | 7 ++-- .../handlers/__tests__/install-source.test.ts | 4 +- .../__tests__/session-clipboard.test.ts | 7 ++-- .../handlers/__tests__/session-state.test.ts | 6 +-- src/platform-runtime-gateway.test.ts | 6 +-- 18 files changed, 129 insertions(+), 59 deletions(-) diff --git a/packages/contracts/src/clipboard-runtime.test.ts b/packages/contracts/src/clipboard-runtime.test.ts index 8ee5f845ba..eee72ae1c9 100644 --- a/packages/contracts/src/clipboard-runtime.test.ts +++ b/packages/contracts/src/clipboard-runtime.test.ts @@ -21,18 +21,51 @@ const device = { const local = (resolveInteractor: LocalInteractorOperationResolver) => localInteractorSource({ device, resolveInteractor }); -test('builds the exact clipboard operation fact catalog', () => { +test('builds the exact clipboard operation fact catalog for an owner that names both halves', () => { const read = { available: true } as const; const write = { available: false, reason: 'owner-capability-missing', } as const; - expect(clipboardRuntimeOperationFacts({ read, write })).toEqual({ + expect( + clipboardRuntimeOperationFacts({ + unsupported: write, + read, + write, + }), + ).toEqual({ readClipboard: read, writeClipboard: write, }); }); +test('a half the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified half and never an implied success', () => { + const denial = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'clipboard is not supported on Vega OS.', + } as const; + + expect( + clipboardRuntimeOperationFacts({ unsupported: denial, read: { available: true } }), + ).toEqual({ + readClipboard: { available: true }, + writeClipboard: denial, + }); +}); + +test('an owner serving neither clipboard half names the family denial once and still answers with the exhaustive shape', () => { + const denial = { available: false, reason: 'unsupported-platform-leaf' } as const; + + const facts = clipboardRuntimeOperationFacts({ unsupported: denial }); + + expect(facts).toEqual({ + readClipboard: denial, + writeClipboard: denial, + }); + expect(Object.isFrozen(facts)).toBe(true); +}); + test('a local read binding returns the interactor pasteboard text verbatim', async () => { const readClipboard = vi.fn(async () => 'copied\ntext'); const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); diff --git a/packages/contracts/src/clipboard-runtime.ts b/packages/contracts/src/clipboard-runtime.ts index dcbb033981..bb60e8021e 100644 --- a/packages/contracts/src/clipboard-runtime.ts +++ b/packages/contracts/src/clipboard-runtime.ts @@ -1,5 +1,5 @@ import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; /** @@ -41,14 +41,32 @@ export type ClipboardRuntimeOperationFacts = Readonly<{ }>; /** - * Read and write are separate cells because an owner can genuinely have one without the other — - * a WebDriver provider whose Appium clipboard extension exposes only a getter is the real case — - * and `clipboard read` must not be refused because the write half is missing. + * What an owner declares about the clipboard. Read and write stay separate cells because an owner + * can genuinely have one without the other — a WebDriver provider whose Appium clipboard extension + * exposes only a getter is the real case — and `clipboard read` must not be refused because the + * write half is missing. + * + * Both halves ride one shell command set on every other owner, so an owner with neither names + * `unsupported` once and a half it never names reports that denial verbatim: omission is a + * classified refusal, never an unclassified half and never an implied success. An owner states a + * half only to say something the family denial does not. */ +export type ClipboardRuntimeOperationFactsInput = Readonly<{ + unsupported: RuntimeOperationUnavailability; + read?: RuntimeOperationFact; + write?: RuntimeOperationFact; +}>; + +/** Builds the exhaustive owner claims for the two clipboard operations. */ export function clipboardRuntimeOperationFacts( - input: Readonly<{ read: RuntimeOperationFact; write: RuntimeOperationFact }>, + input: ClipboardRuntimeOperationFactsInput, ): ClipboardRuntimeOperationFacts { - return Object.freeze({ readClipboard: input.read, writeClipboard: input.write }); + const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact => + fact ?? input.unsupported; + return Object.freeze({ + readClipboard: declared(input.read), + writeClipboard: declared(input.write), + }); } /** diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 306dcd4506..1c5580af59 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -48,8 +48,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { orientation: { available: false, reason: 'unsupported-provider-mode' }, tvRemote: { available: false, reason: 'unsupported-provider-mode' }, keyboard: { available: false, reason: 'unsupported-provider-mode' }, - readClipboard: { available: false, reason: 'unsupported-provider-mode' }, - writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, + clipboard: { available: false, reason: 'unsupported-provider-mode' }, appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, setSetting: { available: false, reason: 'unsupported-provider-mode' }, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 9735b0575e..de682a1f04 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -62,8 +62,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ orientation: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; - readClipboard: RuntimeOperationUnavailability; - writeClipboard: RuntimeOperationUnavailability; + clipboard: RuntimeOperationUnavailability; appSwitcher: RuntimeOperationUnavailability; triggerAppEvent: RuntimeOperationUnavailability; setSetting: RuntimeOperationUnavailability; @@ -118,8 +117,7 @@ const UNAVAILABLE_CELLS = { orientation: true, tvRemote: true, keyboard: true, - readClipboard: true, - writeClipboard: true, + clipboard: true, appSwitcher: true, triggerAppEvent: true, setSetting: true, @@ -239,10 +237,7 @@ export function createUnavailablePlatformRuntimeFacts( ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), - ...clipboardRuntimeOperationFacts({ - read: frozen.readClipboard, - write: frozen.writeClipboard, - }), + ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), ...appSwitcherRuntimeOperationFacts({ appSwitcher: frozen.appSwitcher }), ...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }), ...settingsRuntimeOperationFacts({ setSetting: frozen.setSetting }), diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 0e0ed64cba..de13abd5ba 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -377,7 +377,11 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), // Read and write share one cell: `cmd clipboard` either has a shell implementation on this // build or it has none, and no Android build ships one half of it. - ...clipboardRuntimeOperationFacts({ read: clipboardCell, write: clipboardCell }), + ...clipboardRuntimeOperationFacts({ + unsupported: clipboardShellUnavailable, + read: clipboardCell, + write: clipboardCell, + }), ...audioProbeRuntimeOperationFacts({ capture: androidAudioProbeCaptureFact(device), query: audioQueryUnavailable, diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index 4aaab146ce..d697abe881 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -4,7 +4,10 @@ import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboar import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import type { + RuntimeOperationFact, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform-runtime'; import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; const available = Object.freeze({ available: true } as const); @@ -113,6 +116,14 @@ function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { return resolveDeviceAppleOs(device) === 'watchos' ? appleWatchOsUnavailable : available; } +/** The clipboard denial this leaf reports for a surface this owner does not name. */ +function appleClipboardFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability { + if (device.kind !== 'simulator' && device.kind !== 'device') return clipboardKindUnavailable; + return resolveDeviceAppleOs(device) === 'watchos' + ? appleWatchOsUnavailable + : clipboardLeafUnavailable; +} + /** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */ export function appleSystemFacts(device: DeviceInfo) { const clipboard = appleClipboardFact(device); @@ -120,7 +131,11 @@ export function appleSystemFacts(device: DeviceInfo) { // press its buttons, so splitting them would invent a cell no Apple owner is ever in. const alert = appleAlertFact(device); return Object.freeze({ - ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), + ...clipboardRuntimeOperationFacts({ + unsupported: appleClipboardFamilyUnavailable(device), + read: clipboard, + write: clipboard, + }), ...alertRuntimeOperationFacts({ read: alert, wait: alert, accept: alert, dismiss: alert }), ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), ...settingsRuntimeOperationFacts({ diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 5e2412f51b..853c0ba378 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -296,10 +296,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor enter: harmonyFocusFact(device), }), // HarmonyOS exposes no clipboard automation operation. - ...clipboardRuntimeOperationFacts({ - read: harmonyPlatformLeafUnavailable, - write: harmonyPlatformLeafUnavailable, - }), + ...clipboardRuntimeOperationFacts({ unsupported: harmonyPlatformLeafUnavailable }), ...audioProbeRuntimeOperationFacts({ capture: audioProbeUnavailable, query: audioProbeUnavailable, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 91f668cc6f..d00c6d8be7 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -196,8 +196,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts home: homeKindUnavailable, orientation: linuxPlatformLeafUnavailable, tvRemote: linuxPlatformLeafUnavailable, - readClipboard: clipboardKindUnavailable, - writeClipboard: clipboardKindUnavailable, + clipboard: clipboardKindUnavailable, // The Linux interactor's own `appSwitcher` throws unsupported, and the retired descriptor // declared `linux: {}`, so no Linux cell was ever admitted. appSwitcher: linuxPlatformLeafUnavailable, @@ -269,6 +268,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // Parity with the retired `clipboard` capability bucket (`{ device: true }`): wl-clipboard // / xclip / xsel drive the desktop session's selection, and no other Linux cell has one. ...clipboardRuntimeOperationFacts({ + unsupported: clipboardKindUnavailable, read: linuxDesktopFact(device, clipboardKindUnavailable), write: linuxDesktopFact(device, clipboardKindUnavailable), }), diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 279f51f8c3..3e9b809df7 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -191,8 +191,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts home: homeUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, - readClipboard: clipboardUnavailable, - writeClipboard: clipboardUnavailable, + clipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, setSetting: settingsUnavailable, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 0c6b8207da..14fdb58c6b 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -413,10 +413,7 @@ function webRuntimeFacts( ...keyboardRuntimeOperationFacts({ unsupported: navigationUnavailable }), // The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS` // lists `audio` alone), so no clipboard cell was ever admitted here. - ...clipboardRuntimeOperationFacts({ - read: navigationUnavailable, - write: navigationUnavailable, - }), + ...clipboardRuntimeOperationFacts({ unsupported: navigationUnavailable }), // Parity with the retired `WEB_QUERY_COMMANDS` graft, which admitted `audio` on every web // device; the provider that carries no probe transport still refuses at execution. ...audioProbeRuntimeOperationFacts({ capture: audioCaptureUnavailable, query: available }), diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 92562f4d4b..e3863fd286 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -109,8 +109,7 @@ export function createLimrunPlatformRuntimeOwner( orientation: liveSessionUnavailable, tvRemote: liveSessionUnavailable, keyboard: liveSessionUnavailable, - readClipboard: liveSessionUnavailable, - writeClipboard: liveSessionUnavailable, + clipboard: liveSessionUnavailable, appSwitcher: liveSessionUnavailable, triggerAppEvent: liveSessionUnavailable, setSetting: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 80343e8811..3e3f318eb5 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -59,6 +59,11 @@ const gestureUnsupportedProviderMode = Object.freeze({ available: false, reason: 'unsupported-provider-mode', } as const); +/** What an unnamed clipboard half reports on a live Limrun session. */ +const clipboardUnsupportedProviderMode = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', +} as const); const androidTvMultiTouchUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -267,9 +272,23 @@ export function limrunClipboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, ) { - const cell = - liveSessionUnavailable ?? (device.platform === 'android' ? available : clipboardUnavailableIos); - return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); + if (liveSessionUnavailable) { + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ unsupported: liveSessionUnavailable }), + }); + } + if (device.platform !== 'android') { + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ unsupported: clipboardUnavailableIos }), + }); + } + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ + unsupported: clipboardUnsupportedProviderMode, + read: available, + write: available, + }), + }); } const alertUnavailableIos = Object.freeze({ diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 2e82d2952a..c011a140d2 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -519,8 +519,7 @@ function webDriverFacts( orientation: inactiveSession, tvRemote: inactiveSession, keyboard: inactiveSession, - readClipboard: inactiveSession, - writeClipboard: inactiveSession, + clipboard: inactiveSession, appSwitcher: inactiveSession, triggerAppEvent: inactiveSession, setSetting: inactiveSession, @@ -563,8 +562,7 @@ function webDriverFacts( orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, keyboard: keyboardUnavailable, - readClipboard: clipboardUnavailable, - writeClipboard: clipboardUnavailable, + clipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, setSetting: settingsUnavailable, @@ -653,6 +651,7 @@ function webDriverFacts( // Appium — which does expose the clipboard extension. The refusal moves to where it can be // true: the interactor, per session. ...clipboardRuntimeOperationFacts({ + unsupported: clipboardUnavailable, read: declared('clipboard.read', clipboardUnavailable), write: declared('clipboard.write', clipboardUnavailable), }), diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index fb3b415be4..c12d93236a 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -1,5 +1,6 @@ import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -60,8 +61,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre setOrientation: unavailable, tvRemote: unavailable, ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), - readClipboard: unavailable, - writeClipboard: unavailable, + ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, @@ -119,8 +119,7 @@ export function createUnavailableRuntimeFactsForTest( orientation: fact, tvRemote: fact, keyboard: fact, - readClipboard: fact, - writeClipboard: fact, + clipboard: fact, appSwitcher: fact, triggerAppEvent: fact, setSetting: fact, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 338364d7dd..da71443415 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -14,6 +14,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime'; import { perfRuntimeOperationFacts } from '@agent-device/contracts/perf-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; @@ -377,8 +378,7 @@ function sourceRuntimeFacts( setOrientation: unavailable, tvRemote: unavailable, ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), - readClipboard: unavailable, - writeClipboard: unavailable, + ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index 280fa76359..8b99e82bb4 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -51,9 +51,10 @@ function harness( const writeClipboard = vi.fn(async () => undefined); const runtimeFacts: RuntimeFacts = { device: { ...deviceShape(device), providerMode: 'local' }, - operations: clipboardRuntimeOperationFacts( - facts, - ) as RuntimeFacts['operations'], + operations: clipboardRuntimeOperationFacts({ + unsupported: unavailable, + ...facts, + }) as RuntimeFacts['operations'], }; const binding = { device, diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 27485b29c0..3f2dfcd976 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -59,8 +59,7 @@ test('boot rejects --headless outside Android directly', async () => { orientation: { available: false, reason: 'owner-capability-missing' }, tvRemote: { available: false, reason: 'owner-capability-missing' }, keyboard: { available: false, reason: 'owner-capability-missing' }, - readClipboard: { available: false, reason: 'owner-capability-missing' }, - writeClipboard: { available: false, reason: 'owner-capability-missing' }, + clipboard: { available: false, reason: 'owner-capability-missing' }, appSwitcher: { available: false, reason: 'owner-capability-missing' }, triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, setSetting: { available: false, reason: 'owner-capability-missing' }, @@ -166,8 +165,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () orientation: { available: false, reason: 'unsupported-platform-leaf' }, tvRemote: { available: false, reason: 'unsupported-platform-leaf' }, keyboard: { available: false, reason: 'unsupported-platform-leaf' }, - readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, - writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, + clipboard: { available: false, reason: 'unsupported-platform-leaf' }, appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, setSetting: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index ff2e292b82..5d151cfff6 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -67,8 +67,7 @@ describe('composed platform runtime gateway', () => { orientation: unavailable, tvRemote: unavailable, keyboard: unavailable, - readClipboard: unavailable, - writeClipboard: unavailable, + clipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, @@ -166,8 +165,7 @@ describe('composed platform runtime gateway', () => { orientation: unavailable, tvRemote: unavailable, keyboard: unavailable, - readClipboard: unavailable, - writeClipboard: unavailable, + clipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, From d5185f8b582e63b9a20e2df8da9bd0c1be68fed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 10 Sep 2026 16:29:36 +0200 Subject: [PATCH 2/2] fix(contracts): derive clipboard family denials from the served cell Review follow-up on the clipboard family. Apple restated its leaf branches to choose a clipboard denial, so the leaf split lived twice in one file. It now reads the leaf's own refusal, keeping a leaf-scoped placeholder only where the leaf serves clipboard and therefore has no clipboard refusal to state. Android named a build-level shell verdict as its family denial, which is the claim its own probe refuses to make when the probe did not complete; the denial now follows the probed cell, so an unnamed half reports the unknown rather than a verdict. Pin the shared unavailable record's clipboard fan-out the way the keyboard commit pinned its own: one input cell, two operations, each with its reason. --- .../src/platform-runtime-unavailable.test.ts | 6 ++++++ packages/platform-android/src/runtime.ts | 2 +- packages/platform-apple/src/system/runtime.ts | 11 ++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 1c5580af59..bfff68ccfc 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -95,6 +95,12 @@ test('generic unavailable binding preserves exact provider ownership and mode', reason: 'unsupported-provider-mode', }); } + for (const operation of ['readClipboard', 'writeClipboard'] as const) { + assert.deepEqual(binding.facts.operations[operation], { + available: false, + reason: 'unsupported-provider-mode', + }); + } // `apps` is left unclassified above (an optional cell): it inherits the network gap's reason. assert.deepEqual(binding.facts.operations.listApps, { available: false, diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index de13abd5ba..99a66bbc9d 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -378,7 +378,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // Read and write share one cell: `cmd clipboard` either has a shell implementation on this // build or it has none, and no Android build ships one half of it. ...clipboardRuntimeOperationFacts({ - unsupported: clipboardShellUnavailable, + unsupported: clipboardCell.available ? clipboardShellUnavailable : clipboardCell, read: clipboardCell, write: clipboardCell, }), diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index d697abe881..b5cef7d877 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -116,12 +116,13 @@ function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { return resolveDeviceAppleOs(device) === 'watchos' ? appleWatchOsUnavailable : available; } -/** The clipboard denial this leaf reports for a surface this owner does not name. */ +/** + * The clipboard denial this leaf reports for a half it does not name: the leaf's own refusal where + * it has one, since a leaf that serves clipboard today has no clipboard refusal to state. + */ function appleClipboardFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability { - if (device.kind !== 'simulator' && device.kind !== 'device') return clipboardKindUnavailable; - return resolveDeviceAppleOs(device) === 'watchos' - ? appleWatchOsUnavailable - : clipboardLeafUnavailable; + const cell = appleClipboardFact(device); + return cell.available ? clipboardLeafUnavailable : cell; } /** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */