From 085df8cb9d54344ae2f42b2fc51cba024873242e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:23:23 +0200 Subject: [PATCH 1/5] fix(apple): refuse fold on a scoped simulator set at runtime admission fold on an iPhone Duo inside --ios-simulator-device-set failed after the HID send because CoreDevice's display inventory and hinge-angle readback resolve a scoped simulator as not found, so ADR 0025's post-dispatch verification is impossible. Refuse it instead at the owning setFoldPose fact when device.simulatorSetPath is present, with the typed unsupported-device-scope reason; the routed response derives the same reason and a hint naming the set. The default set and the existing kind/OS refusals are unchanged. --- packages/contracts/src/platform-runtime.ts | 2 + .../src/foldable/runtime.test.ts | 104 ++++++++++++++++++ .../platform-apple/src/foldable/runtime.ts | 21 +++- packages/platform-apple/src/runtime.test.ts | 22 ++++ src/commands/schema/cli-help.ts | 2 +- src/commands/system/index.ts | 2 +- src/daemon/__tests__/fold-runtime.test.ts | 6 + src/daemon/fold-runtime.ts | 11 ++ website/docs/docs/commands.md | 1 + 9 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 packages/platform-apple/src/foldable/runtime.test.ts diff --git a/packages/contracts/src/platform-runtime.ts b/packages/contracts/src/platform-runtime.ts index 9170994551..dff20619e5 100644 --- a/packages/contracts/src/platform-runtime.ts +++ b/packages/contracts/src/platform-runtime.ts @@ -126,6 +126,7 @@ export type RuntimeOperationUnavailability = Readonly<{ | 'unsupported-platform-leaf' | 'unsupported-device-kind' | 'unsupported-device-backend' + | 'unsupported-device-scope' | 'unsupported-provider-mode' | 'owner-capability-missing'; hint?: string; @@ -406,6 +407,7 @@ function isRuntimeOperationUnavailabilityReason( value === 'unsupported-platform-leaf' || value === 'unsupported-device-kind' || value === 'unsupported-device-backend' || + value === 'unsupported-device-scope' || value === 'unsupported-provider-mode' || value === 'owner-capability-missing' ); diff --git a/packages/platform-apple/src/foldable/runtime.test.ts b/packages/platform-apple/src/foldable/runtime.test.ts new file mode 100644 index 0000000000..bf5b3dbb69 --- /dev/null +++ b/packages/platform-apple/src/foldable/runtime.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('./pose.ts', () => ({ setAppleFoldPose: vi.fn() })); + +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import { + foldRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; + +import { appleFoldableFacts, createAppleFoldableOperations } from './runtime.ts'; +import { setAppleFoldPose } from './pose.ts'; + +const mockPose = vi.mocked(setAppleFoldPose); + +const duo: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: '4F879835-4AB3-4046-B033-5AB769209DD4', + name: 'iPhone Duo', + kind: 'simulator', + target: 'mobile', + booted: true, +}; +const scopedDuo: DeviceInfo = { ...duo, simulatorSetPath: '/tmp/scoped-set' }; + +/** A full owner binding whose fold cell is derived only from the fact under test. */ +function foldBinding(device: DeviceInfo): DeviceBinding { + const { setFoldPose } = appleFoldableFacts(device); + return { + device, + owner: localRuntimeOwner('apple'), + facts: { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: { setFoldPose } as RuntimeFacts['operations'], + }, + operations: createAppleFoldableOperations({ device, signal: new AbortController().signal }), + [Symbol.asyncDispose]: async () => {}, + }; +} + +function thrownBy(run: () => T): unknown { + try { + run(); + } catch (error) { + return error; + } + throw new Error('expected the routed fold binding to refuse'); +} + +beforeEach(() => { + mockPose.mockReset(); +}); + +test('the default-set iPhone Duo admits setFoldPose and binds the pose operation', () => { + expect(appleFoldableFacts(duo).setFoldPose).toEqual({ available: true }); + expect( + createAppleFoldableOperations({ device: duo, signal: new AbortController().signal }), + ).toHaveProperty('setFoldPose', expect.any(Function)); +}); + +test('a scoped simulator set refuses setFoldPose with the typed unsupported-device-scope fact', () => { + expect(appleFoldableFacts(scopedDuo).setFoldPose).toMatchObject({ + available: false, + reason: 'unsupported-device-scope', + hint: expect.stringContaining('/tmp/scoped-set'), + }); +}); + +test('a scoped simulator set binds no pose operation and never reaches a hinge', () => { + const operations = createAppleFoldableOperations({ + device: scopedDuo, + signal: new AbortController().signal, + }); + expect(operations).not.toHaveProperty('setFoldPose'); + expect(mockPose).not.toHaveBeenCalled(); +}); + +test('the routed fold response refuses a scoped simulator set with the typed reason before any tool call', () => { + const refusal = thrownBy(() => narrowDeviceBinding(foldBinding(scopedDuo), foldRuntimeUse)) as { + code?: string; + details?: { reason?: string; hint?: string }; + }; + expect(refusal.code).toBe('UNSUPPORTED_OPERATION'); + expect(refusal.details?.reason).toBe('unsupported-device-scope'); + expect(refusal.details?.hint).toContain('/tmp/scoped-set'); + expect(mockPose).not.toHaveBeenCalled(); +}); + +test('a scoped set never outranks the existing kind and OS refusals', () => { + expect(appleFoldableFacts({ ...scopedDuo, kind: 'device' }).setFoldPose).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + }); + expect( + appleFoldableFacts({ ...scopedDuo, appleOs: 'tvos', target: 'tv' }).setFoldPose, + ).toMatchObject({ available: false, reason: 'unsupported-platform-leaf' }); +}); diff --git a/packages/platform-apple/src/foldable/runtime.ts b/packages/platform-apple/src/foldable/runtime.ts index c5310f0b31..e9761d94f2 100644 --- a/packages/platform-apple/src/foldable/runtime.ts +++ b/packages/platform-apple/src/foldable/runtime.ts @@ -19,16 +19,33 @@ const foldOsUnavailable = Object.freeze({ hint: 'fold poses the hinge of a foldable iPhone; tvOS, macOS, watchOS and visionOS simulators have no hinge.', } as const); +/** + * A fold inside a scoped simulator set cannot verify its pose: the HID send honours `--set`, but + * `devicectl device info displays` and `devicectl device motion hinge-angle` take only `--device` + * and resolve a scoped simulator as `not found`, so ADR 0025's post-dispatch readback is impossible. + * The refusal is the owning fact, before any display probe or HID effect; the default set is unaffected. + */ +function foldScopeUnavailable(simulatorSetPath: string) { + return { + available: false, + reason: 'unsupported-device-scope', + hint: `fold cannot resolve a simulator scoped to the set at "${simulatorSetPath}": CoreDevice's display inventory and hinge-angle readback need a simulator in the default set, so the pose could not be verified. Run fold without --ios-simulator-device-set.`, + } as const; +} + /** * The simulator leaf that can carry a hinge: iPhone and iPad. Which *model* inside it actually * folds is not in `DeviceInfo`, so the operation answers that from CoreDevice's display table and * refuses a single-panel simulator with a typed `UNSUPPORTED_OPERATION`, the way the runner - * answers for the Action Button hardware. + * answers for the Action Button hardware. A simulator scoped to a non-default set is refused here + * too: its pose cannot be read back through CoreDevice, so no display probe or HID effect runs. */ function appleFoldFact(device: DeviceInfo): RuntimeOperationFact { if (device.kind !== 'simulator') return foldKindUnavailable; const os = resolveDeviceAppleOs(device); - return os === 'ios' || os === 'ipados' ? available : foldOsUnavailable; + if (os !== 'ios' && os !== 'ipados') return foldOsUnavailable; + if (device.simulatorSetPath) return foldScopeUnavailable(device.simulatorSetPath); + return available; } /** The foldable cell: `setFoldPose`. */ diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index fc717d51d9..fcd1b6ed25 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -282,6 +282,28 @@ test.each(Object.entries(leaves))( }, ); +// A simulator scoped to a non-default set is refused by the owner's own fact, before any display +// probe or HID effect: CoreDevice's display inventory and hinge-angle readback cannot resolve it, +// so ADR 0025's post-dispatch verification is impossible. The default-set iOS leaf stays admitted. +test('refuses the fold fact for a scoped simulator set and binds no pose operation', async () => { + const scoped = appleDevice({ simulatorSetPath: '/tmp/scoped-set' }); + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device: scoped, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.setFoldPose).toMatchObject({ + available: false, + reason: 'unsupported-device-scope', + hint: expect.stringContaining('/tmp/scoped-set'), + }); + expect(binding.operations.setFoldPose).toBeUndefined(); +}); + /** * The Action Button is a physical control on iPhone and iPad leaves only. visionOS is the leaf that * separates this from `orientation`'s mobile-input reading: a headset has a Digital Crown and no diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 9177607575..444a845d1d 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -749,7 +749,7 @@ Screens are handled for you: Changing the pose: agent-device fold closed | half-open | open - fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles. A single-panel simulator fails with UNSUPPORTED_OPERATION. + fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles. A single-panel simulator fails with UNSUPPORTED_OPERATION. A simulator scoped to a non-default set with --ios-simulator-device-set is refused before any hinge is touched, with UNSUPPORTED_OPERATION and reason unsupported-device-scope: the HID send honors the set, but CoreDevice's display inventory and hinge-angle readback resolve a scoped simulator as not found, so the pose could not be verified. Run fold without --ios-simulator-device-set (in the default set). Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold; refs and coordinates from before it are stale, and the command's message says so. Timed motion: fold --keyframes '[{"atMs":0,"angle":0},{"atMs":5000,"angle":180}]'. Use 2–64 frames starting at 0ms, increasing integer timestamps up to 60000ms, and angles from 0 to 180. The last timestamp sets motion duration, excluding setup and verification. Equal angles hold; cancellation stops motion. See the fold examples in the command and Node API documentation for trajectories. diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 448d3b51c0..d00badb3bd 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -72,7 +72,7 @@ const homeCommandDescription = 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.'; const orientationCommandDescription = 'Set device orientation on iOS and Android'; const foldCommandDescription = - 'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required.'; + 'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required. A simulator scoped to a non-default simulator set is refused with UNSUPPORTED_OPERATION and reason unsupported-device-scope before any hinge is touched, because CoreDevice cannot read back a scoped simulator; run fold against a simulator in the default set.'; const appSwitcherCommandDescription = 'Open the device app switcher to inspect or change foreground apps. This changes the visible system UI and may move focus away from the current app.'; const keyboardCommandDescription = diff --git a/src/daemon/__tests__/fold-runtime.test.ts b/src/daemon/__tests__/fold-runtime.test.ts index ed41d0d900..6dfb55f1b1 100644 --- a/src/daemon/__tests__/fold-runtime.test.ts +++ b/src/daemon/__tests__/fold-runtime.test.ts @@ -159,6 +159,12 @@ test('rejects an unavailable exact-owner fact before binding', async () => { command: 'fold', device: testDevice, unavailable, + // The fold route derives its refusal from the fact, so the wire error keeps the typed reason. + refusal: { + code: 'UNSUPPORTED_OPERATION', + message: 'fold is not supported on this device', + details: { reason: 'unsupported-platform-leaf' }, + }, }); }); diff --git a/src/daemon/fold-runtime.ts b/src/daemon/fold-runtime.ts index 593df43cfe..b7efac4f9d 100644 --- a/src/daemon/fold-runtime.ts +++ b/src/daemon/fold-runtime.ts @@ -8,6 +8,7 @@ import { import { foldRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { errorResponse } from '@agent-device/kernel/contracts'; import { successText } from '@agent-device/kernel/success-text'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; @@ -43,6 +44,16 @@ export async function resolveBoundFoldRuntime( use: foldRuntimeUse, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, + // Derive the refusal from the same `setFoldPose` fact that gates admission, so the command + // response keeps the fact's typed reason (e.g. `unsupported-device-scope`) and hint rather + // than the shared hint-only wording; capability and response never diverge. + unavailableResponse: (unavailable) => + errorResponse( + 'UNSUPPORTED_OPERATION', + 'fold is not supported on this device', + { reason: unavailable.reason }, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ), }, (runtime) => executeSetFoldPose(runtime, input), ); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 55319492a5..ab965dabf7 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -93,6 +93,7 @@ agent-device fold open - `fold ` puts a foldable iPhone simulator (iPhone Duo) into a hinge pose. The command sends a private HID hinge event inside the selected simulator (ADR 0025), then reads the hinge angle back with `devicectl device motion hinge-angle` and reports the pose only when that reading agrees: `closed` is 0°, `open` is 180°, and `half-open` is any angle between them (requested at 130°). An angle inside that interval only proves the category, so `half-open` is reported once two consecutive readings both fall inside it and agree within 0.5°. The response names the panel the device now lights and its native panel point size, marked `coordinateSpace: "native-panel"`; that size is the panel's own geometry, not the next snapshot's viewport (a 669x951 inner panel can host a 951x669 app window), so it cannot place a tap. Re-snapshot afterwards, and never carry refs or coordinates across a `fold`. After that snapshot, taps, long presses, and scrolling follow the app window on the active panel in closed, half-open, and open poses. - For timed motion, use `fold --keyframes '[{"atMs":0,"angle":0},{"atMs":1667,"angle":160},{"atMs":3333,"angle":100},{"atMs":5000,"angle":180}]'`. This runs the opening/reversal/reopening sequence over five seconds. Supply either a preset or keyframes, never both. Use 2–64 keyframes starting at 0ms with strictly increasing integer timestamps up to 60,000ms and finite angles in 0–180°. Linear interpolation runs at roughly 60Hz; equal consecutive angles hold the hinge. Motion duration excludes preparation and final-angle verification. Cancellation stops at the current angle; re-snapshot even after an interrupted trajectory. - `fold` is simulator-only and requires an Xcode toolchain with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1). It runs a helper against the session UDID; the helper is built once per `Fold.m` source hash and Xcode toolchain, cached under `~/.agent-device/fold-helper`, and rebuilt only when the source or the toolchain changes. Device Hub and host Accessibility permission are not required. Build failures report `fold-helper-build-failed`; dispatch failures report `fold-hid-dispatch-failed`. There is no UI fallback. Single-panel simulators and physical devices are refused. +- A simulator scoped to a non-default set with `--ios-simulator-device-set` is refused before any hinge is touched with `UNSUPPORTED_OPERATION` and `details.reason: "unsupported-device-scope"`. The HID send accepts `--set`, but `devicectl device info displays` and `devicectl device motion hinge-angle` accept only `--device` and resolve a scoped simulator as not found, so the pose could not be read back (ADR 0025). Run `fold` against a simulator in the default set. - `fold` costs one bounded hinge stream per read, and devicectl's smallest stream is five seconds: `closed` and `open` take about ten seconds, `half-open` about sixteen, because the hinge animates and the command waits for it to stop. A hinge whose last reading is some other pose fails with `COMMAND_FAILED` and `reason: fold-pose-unverified`, naming the angle CoreDevice still reports. A hinge seen `half-open` but never at rest fails with `reason: fold-pose-unsettled`, naming the observed and previous angles: the requested category was observed, and what is missing is a pose the hinge holds (#2730). - `action-button` is not a cheap command to loop. On an iPhone 17 Pro Simulator the press itself spent about five seconds inside XCUITest, while `home` and `app-switcher` on the same session took under two seconds each. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. From c42b1b8abf57df526f41c9cf8558ee8ba5e69e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:47:38 +0200 Subject: [PATCH 2/5] test(apple): prove the scoped fold refusal on the daemon route Move the zero-side-effect proof onto the real fold route: a scoped setFoldPose fact is refused at admission, so the wire error carries the typed unsupported-device-scope reason and the set name and the device is never bound. Refocus the colocated narrowDeviceBinding test to what it proves, and collapse the repeated CoreDevice-scope rationale to one owning comment. --- .../src/foldable/runtime.test.ts | 3 +- .../platform-apple/src/foldable/runtime.ts | 4 +- src/daemon/__tests__/fold-runtime.test.ts | 38 +++++++++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/platform-apple/src/foldable/runtime.test.ts b/packages/platform-apple/src/foldable/runtime.test.ts index bf5b3dbb69..4eaf1525e8 100644 --- a/packages/platform-apple/src/foldable/runtime.test.ts +++ b/packages/platform-apple/src/foldable/runtime.test.ts @@ -82,7 +82,7 @@ test('a scoped simulator set binds no pose operation and never reaches a hinge', expect(mockPose).not.toHaveBeenCalled(); }); -test('the routed fold response refuses a scoped simulator set with the typed reason before any tool call', () => { +test('the narrowed fold use refuses a scoped simulator set with the typed fact reason and hint', () => { const refusal = thrownBy(() => narrowDeviceBinding(foldBinding(scopedDuo), foldRuntimeUse)) as { code?: string; details?: { reason?: string; hint?: string }; @@ -90,7 +90,6 @@ test('the routed fold response refuses a scoped simulator set with the typed rea expect(refusal.code).toBe('UNSUPPORTED_OPERATION'); expect(refusal.details?.reason).toBe('unsupported-device-scope'); expect(refusal.details?.hint).toContain('/tmp/scoped-set'); - expect(mockPose).not.toHaveBeenCalled(); }); test('a scoped set never outranks the existing kind and OS refusals', () => { diff --git a/packages/platform-apple/src/foldable/runtime.ts b/packages/platform-apple/src/foldable/runtime.ts index e9761d94f2..5460a42c4e 100644 --- a/packages/platform-apple/src/foldable/runtime.ts +++ b/packages/platform-apple/src/foldable/runtime.ts @@ -37,8 +37,8 @@ function foldScopeUnavailable(simulatorSetPath: string) { * The simulator leaf that can carry a hinge: iPhone and iPad. Which *model* inside it actually * folds is not in `DeviceInfo`, so the operation answers that from CoreDevice's display table and * refuses a single-panel simulator with a typed `UNSUPPORTED_OPERATION`, the way the runner - * answers for the Action Button hardware. A simulator scoped to a non-default set is refused here - * too: its pose cannot be read back through CoreDevice, so no display probe or HID effect runs. + * answers for the Action Button hardware. A simulator scoped to a non-default set is refused too; + * see {@link foldScopeUnavailable}. */ function appleFoldFact(device: DeviceInfo): RuntimeOperationFact { if (device.kind !== 'simulator') return foldKindUnavailable; diff --git a/src/daemon/__tests__/fold-runtime.test.ts b/src/daemon/__tests__/fold-runtime.test.ts index 6dfb55f1b1..fc0f38f44e 100644 --- a/src/daemon/__tests__/fold-runtime.test.ts +++ b/src/daemon/__tests__/fold-runtime.test.ts @@ -16,7 +16,7 @@ import { foldRuntimeUse, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import { deviceShape } from '@agent-device/kernel/device'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; @@ -60,13 +60,14 @@ function runtimeHarness( pose: 'open', hingeAngleDegrees: 180, })), + device: DeviceInfo = testDevice, ) { const facts: RuntimeFacts = { - device: { ...deviceShape(testDevice), providerMode: 'local' }, + device: { ...deviceShape(device), providerMode: 'local' }, operations: { setFoldPose: fact } as RuntimeFacts['operations'], }; const binding = { - device: testDevice, + device, owner: localRuntimeOwner('apple'), facts, operations: { setFoldPose }, @@ -168,6 +169,37 @@ test('rejects an unavailable exact-owner fact before binding', async () => { }); }); +// The scoped-set refusal is a real route outcome, not just a narrowed binding: a scoped session's +// `setFoldPose` fact refuses on admission, so the wire error carries the typed reason and the set +// name, and the device is never bound or posed. +test('refuses a scoped simulator set on the route with the typed reason, never binding', async () => { + const scopedDevice: DeviceInfo = { ...testDevice, simulatorSetPath: '/tmp/scoped-set' }; + const scopeRefusal = { + available: false, + reason: 'unsupported-device-scope', + hint: 'fold cannot resolve a simulator scoped to the set at "/tmp/scoped-set".', + } as const; + const harness = runtimeHarness(scopeRefusal, vi.fn(), scopedDevice); + + const resolved = await resolveBoundFoldRuntime({ + device: scopedDevice, + positionals: ['half-open'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(false); + if (resolved.ok || resolved.response.ok) throw new Error('the scoped-set fact admitted fold'); + expect(resolved.response.error).toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + message: 'fold is not supported on this device', + hint: expect.stringContaining('/tmp/scoped-set'), + details: { reason: 'unsupported-device-scope' }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); + expect(harness.setFoldPose).not.toHaveBeenCalled(); +}); + test('passes the validated timed intent to the admitted fold owner', async () => { const setFoldPose = vi.fn(async (): Promise => ({ pose: 'half-open', From eebda040af9fd6ed66309a037978d0e5887267dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:57:54 +0200 Subject: [PATCH 3/5] refactor(daemon): share the generic runtime-admission refusal builder The fold override restated the same UNSUPPORTED_OPERATION + ' is not supported on this device' + hint the admission default already built, only to add details.reason. Own that refusal once in admitRuntimeOperations and reuse it: the default is byte-identical, fold passes its typed reason as details, and no other route's wire shape changes. --- src/daemon/fold-runtime.ts | 19 ++++++++----------- src/daemon/runtime-admission.ts | 26 ++++++++++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/daemon/fold-runtime.ts b/src/daemon/fold-runtime.ts index b7efac4f9d..9e49d62034 100644 --- a/src/daemon/fold-runtime.ts +++ b/src/daemon/fold-runtime.ts @@ -8,10 +8,13 @@ import { import { foldRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { errorResponse } from '@agent-device/kernel/contracts'; import { successText } from '@agent-device/kernel/success-text'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; -import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { + resolveBoundGenericRuntime, + unsupportedOperationResponse, + type RuntimeAdmissionBindings, +} from './runtime-admission.ts'; /** `fold `, parsed with the same aliases the CLI reader accepts. */ export function readRequestedFoldPose(positionals: readonly string[]): FoldPose { @@ -44,16 +47,10 @@ export async function resolveBoundFoldRuntime( use: foldRuntimeUse, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, - // Derive the refusal from the same `setFoldPose` fact that gates admission, so the command - // response keeps the fact's typed reason (e.g. `unsupported-device-scope`) and hint rather - // than the shared hint-only wording; capability and response never diverge. + // Reuse the shared generic-route refusal and add the fact's typed reason (e.g. + // `unsupported-device-scope`), so capability and response derive from one fact and never drift. unavailableResponse: (unavailable) => - errorResponse( - 'UNSUPPORTED_OPERATION', - 'fold is not supported on this device', - { reason: unavailable.reason }, - unavailable.hint ? { hint: unavailable.hint } : undefined, - ), + unsupportedOperationResponse('fold', unavailable, { reason: unavailable.reason }), }, (runtime) => executeSetFoldPose(runtime, input), ); diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 8c47cecb4c..4edb337d99 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -42,6 +42,25 @@ export type RuntimeAdmissionRequest = RuntimeAdmissionBindings & export type { RuntimeAdmissionBindings }; +/** + * The refusal every generic runtime-admitted route reports when its exact owner did not admit a + * required operation. The ` is not supported on this device` wording and the hint are + * owned here; a route that carries typed evidence passes it as `details` rather than restating the + * refusal, so its response and the shared default can never drift apart. + */ +export function unsupportedOperationResponse( + command: string, + unavailable: RuntimeOperationUnavailability, + details?: Record, +): DaemonFailureResponse { + return errorResponse( + 'UNSUPPORTED_OPERATION', + `${command} is not supported on this device`, + details, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ); +} + /** * The one facts-admission seam every migrated command route shares. It performs exactly one * side-effect-free inspection and hands back the binding gateway only once the exact device cell @@ -57,12 +76,7 @@ export async function admitRuntimeOperations( if (fact.available) continue; const response = request.unavailableResponse ? request.unavailableResponse(fact) - : errorResponse( - 'UNSUPPORTED_OPERATION', - `${request.command} is not supported on this device`, - undefined, - fact.hint ? { hint: fact.hint } : undefined, - ); + : unsupportedOperationResponse(request.command, fact); return { type: 'response', response }; } return { type: 'admitted', bind: requireDeviceBinding(request.bindDevice) }; From c791997c2af81ee52764a54c23f18c361b8d438f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:21:22 +0200 Subject: [PATCH 4/5] docs(fold): keep the scoped-set rationale on the reference surfaces The terse fold command description restated the same CoreDevice-scope rationale the deep help topic and commands.md carry. The description now states only the refusal, its typed reason, and the fix; the why lives on the surfaces a user opens to read more. --- src/commands/system/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index d00badb3bd..25fe92323f 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -72,7 +72,7 @@ const homeCommandDescription = 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.'; const orientationCommandDescription = 'Set device orientation on iOS and Android'; const foldCommandDescription = - 'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required. A simulator scoped to a non-default simulator set is refused with UNSUPPORTED_OPERATION and reason unsupported-device-scope before any hinge is touched, because CoreDevice cannot read back a scoped simulator; run fold against a simulator in the default set.'; + 'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required. A simulator scoped to a non-default simulator set is refused with UNSUPPORTED_OPERATION and reason unsupported-device-scope; run fold against a simulator in the default set.'; const appSwitcherCommandDescription = 'Open the device app switcher to inspect or change foreground apps. This changes the visible system UI and may move focus away from the current app.'; const keyboardCommandDescription = From f9a85feb040c1b3b1cece8580b780b78add76d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 14:54:17 +0200 Subject: [PATCH 5/5] refactor(daemon): carry the typed reason in the one refusal both runtime seams build The generic route and the request-scoped session handlers each owned a byte-identical UNSUPPORTED_OPERATION refusal, but only the session seam attached details.reason, so the shared default emitted the same sentence with a different wire shape and fold had to re-add the reason through a custom unavailableResponse. unsupportedOperationResponse now always attaches details: { reason } and unavailableRuntimeOperationResponse delegates to it, so one builder owns sentence + reason + hint for both seams. fold drops its override and its second 'fold' mention; conformance asserts the single shape. Nits: cross-link the scoped HID transport test to the owning fact that refuses the scope, and assert hint propagation with a sentinel instead of echoing production wording. --- .../src/foldable/simulator-hid.test.ts | 3 +++ src/daemon/__tests__/fold-runtime.test.ts | 15 +++++---------- src/daemon/__tests__/keyboard-runtime.test.ts | 1 + .../__tests__/runtime-binding-conformance.ts | 3 ++- src/daemon/fold-runtime.ts | 10 +--------- src/daemon/runtime-admission.ts | 11 +++++------ src/daemon/session-runtime-admission.ts | 16 +++++++--------- 7 files changed, 24 insertions(+), 35 deletions(-) diff --git a/packages/platform-apple/src/foldable/simulator-hid.test.ts b/packages/platform-apple/src/foldable/simulator-hid.test.ts index 9cccc35e8f..4874686cb8 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.test.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.test.ts @@ -70,6 +70,9 @@ test('streams all keyframes in one process with a duration-derived timeout', asy expect(dispatches).toBe(1); }); +// Transport-level only: this pins that a pose dispatch routes through runSimctlForDevice, which +// targets the UDID inside its scoped set. It is NOT end-to-end scoped-fold support — `appleFoldFact` +// refuses `unsupported-device-scope` at runtime admission before this dispatch is ever reached. test('HID dispatch addresses the UDID inside its scoped simulator set', async () => { const dispatches: string[][] = []; await withAppleToolProvider( diff --git a/src/daemon/__tests__/fold-runtime.test.ts b/src/daemon/__tests__/fold-runtime.test.ts index fc0f38f44e..693e36587d 100644 --- a/src/daemon/__tests__/fold-runtime.test.ts +++ b/src/daemon/__tests__/fold-runtime.test.ts @@ -160,24 +160,19 @@ test('rejects an unavailable exact-owner fact before binding', async () => { command: 'fold', device: testDevice, unavailable, - // The fold route derives its refusal from the fact, so the wire error keeps the typed reason. - refusal: { - code: 'UNSUPPORTED_OPERATION', - message: 'fold is not supported on this device', - details: { reason: 'unsupported-platform-leaf' }, - }, }); }); // The scoped-set refusal is a real route outcome, not just a narrowed binding: a scoped session's -// `setFoldPose` fact refuses on admission, so the wire error carries the typed reason and the set -// name, and the device is never bound or posed. +// `setFoldPose` fact refuses on admission, so the wire error carries the typed reason and the fact's +// own hint, and the device is never bound or posed. The hint is a sentinel the route could never +// compose, so the assertion proves propagation without re-typing production's wording. test('refuses a scoped simulator set on the route with the typed reason, never binding', async () => { const scopedDevice: DeviceInfo = { ...testDevice, simulatorSetPath: '/tmp/scoped-set' }; const scopeRefusal = { available: false, reason: 'unsupported-device-scope', - hint: 'fold cannot resolve a simulator scoped to the set at "/tmp/scoped-set".', + hint: 'SCOPED-SET-HINT-SENTINEL', } as const; const harness = runtimeHarness(scopeRefusal, vi.fn(), scopedDevice); @@ -193,7 +188,7 @@ test('refuses a scoped simulator set on the route with the typed reason, never b expect(resolved.response.error).toMatchObject({ code: 'UNSUPPORTED_OPERATION', message: 'fold is not supported on this device', - hint: expect.stringContaining('/tmp/scoped-set'), + hint: 'SCOPED-SET-HINT-SENTINEL', details: { reason: 'unsupported-device-scope' }, }); expect(harness.bindDevice).not.toHaveBeenCalled(); diff --git a/src/daemon/__tests__/keyboard-runtime.test.ts b/src/daemon/__tests__/keyboard-runtime.test.ts index 2f76f4b2c9..dcd55296e0 100644 --- a/src/daemon/__tests__/keyboard-runtime.test.ts +++ b/src/daemon/__tests__/keyboard-runtime.test.ts @@ -195,6 +195,7 @@ test('android status is refused on iOS with the retired in-handler hint', async code: 'UNSUPPORTED_OPERATION', message: 'keyboard status is not supported on this device', hint: unavailable.hint, + details: { reason: unavailable.reason }, }, }, }); diff --git a/src/daemon/__tests__/runtime-binding-conformance.ts b/src/daemon/__tests__/runtime-binding-conformance.ts index 87d12bc1ee..bfc125365c 100644 --- a/src/daemon/__tests__/runtime-binding-conformance.ts +++ b/src/daemon/__tests__/runtime-binding-conformance.ts @@ -226,7 +226,7 @@ export async function refuseUnavailableExactOwnerFact( return resolved.response; } -/** The wording `admitRuntimeOperations` gives every single-use route it refuses. */ +/** The wording and shape `admitRuntimeOperations` gives every single-use route it refuses. */ function unsupportedOperationRefusal( command: ConformedRuntimeCommand, unavailable: RuntimeOperationUnavailability, @@ -234,6 +234,7 @@ function unsupportedOperationRefusal( return { code: 'UNSUPPORTED_OPERATION', message: `${command} is not supported on this device`, + details: { reason: unavailable.reason }, ...(unavailable.hint ? { hint: unavailable.hint } : {}), }; } diff --git a/src/daemon/fold-runtime.ts b/src/daemon/fold-runtime.ts index 9e49d62034..593df43cfe 100644 --- a/src/daemon/fold-runtime.ts +++ b/src/daemon/fold-runtime.ts @@ -10,11 +10,7 @@ import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtim import type { DeviceInfo } from '@agent-device/kernel/device'; import { successText } from '@agent-device/kernel/success-text'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; -import { - resolveBoundGenericRuntime, - unsupportedOperationResponse, - type RuntimeAdmissionBindings, -} from './runtime-admission.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; /** `fold `, parsed with the same aliases the CLI reader accepts. */ export function readRequestedFoldPose(positionals: readonly string[]): FoldPose { @@ -47,10 +43,6 @@ export async function resolveBoundFoldRuntime( use: foldRuntimeUse, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, - // Reuse the shared generic-route refusal and add the fact's typed reason (e.g. - // `unsupported-device-scope`), so capability and response derive from one fact and never drift. - unavailableResponse: (unavailable) => - unsupportedOperationResponse('fold', unavailable, { reason: unavailable.reason }), }, (runtime) => executeSetFoldPose(runtime, input), ); diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 4edb337d99..a08b7b466d 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -43,20 +43,19 @@ export type RuntimeAdmissionRequest = RuntimeAdmissionBindings & export type { RuntimeAdmissionBindings }; /** - * The refusal every generic runtime-admitted route reports when its exact owner did not admit a - * required operation. The ` is not supported on this device` wording and the hint are - * owned here; a route that carries typed evidence passes it as `details` rather than restating the - * refusal, so its response and the shared default can never drift apart. + * The one refusal the daemon reports when a device's exact runtime owner did not admit an + * operation. Both seams — this generic route and the request-scoped session handlers — build their + * `UNSUPPORTED_OPERATION` here, so the ` is not supported on this device` sentence, the + * typed `details.reason`, and the hint have a single owner and one wire shape. */ export function unsupportedOperationResponse( command: string, unavailable: RuntimeOperationUnavailability, - details?: Record, ): DaemonFailureResponse { return errorResponse( 'UNSUPPORTED_OPERATION', `${command} is not supported on this device`, - details, + { reason: unavailable.reason }, unavailable.hint ? { hint: unavailable.hint } : undefined, ); } diff --git a/src/daemon/session-runtime-admission.ts b/src/daemon/session-runtime-admission.ts index 905891f2f5..3895c2f98b 100644 --- a/src/daemon/session-runtime-admission.ts +++ b/src/daemon/session-runtime-admission.ts @@ -8,7 +8,7 @@ import { AppError } from '@agent-device/kernel/errors'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import { errorResponse } from '@agent-device/kernel/contracts'; +import { unsupportedOperationResponse } from './runtime-admission.ts'; export type RuntimeCommandHandlerParams = Readonly<{ req: DaemonRequest; @@ -18,18 +18,16 @@ export type RuntimeCommandHandlerParams = Readonly<{ bindDevice?: BindDeviceRuntime; }>; -/** Shared facts-first admission for request-scoped runtime command handlers. */ +/** + * Shared facts-first admission for request-scoped runtime command handlers. Availability is the + * only thing decided here; the refusal wording, `details.reason`, and hint come from the same + * `unsupportedOperationResponse` the generic route uses, so both seams keep one wire shape. + */ export function unavailableRuntimeOperationResponse( command: string, fact: RuntimeOperationFact, ): DaemonResponse | undefined { - if (fact.available) return undefined; - return errorResponse( - 'UNSUPPORTED_OPERATION', - `${command} is not supported on this device`, - { reason: fact.reason }, - fact.hint ? { hint: fact.hint } : undefined, - ); + return fact.available ? undefined : unsupportedOperationResponse(command, fact); } export function requireRuntimeFacts(