diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 7ffb1f9fff..f0976e5726 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -10,9 +10,11 @@ Amended 2026-10-11 by #3431: the daemon-runtime composition edges this ADR classified (the gateway/diagnostics/resource-cleanup/lifecycle edges, the provider-runtime hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it -is no longer a daemon-zone importer. The R76 inventory now carries only the leaked -selector-query edge (`src/daemon/direct-ios-selector.ts`); the section-2 outcomes below remain the -record of how each was classified. +is no longer a daemon-zone importer. #3430 then routed the leaked selector-query edge +(`src/daemon/direct-ios-selector.ts`) through the request-bound runtime binding, so the R76 +inventory is empty; with nothing left to classify, the same change collapsed R76 to a plain ban on +the coupling and deleted the classification machinery, per the rule's own kill criterion. The +section-2 outcomes below remain the record of how each was classified. ## 1. Context @@ -30,9 +32,13 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. ## 2. Decision 1. **Every daemon import of a root `src/platform-runtime-*.ts` module is classified as exactly - one of three categories**, and the classification is owned by the machine-readable inventory + one of three categories** (retired by the 2026-10-12 amendment below: the inventory `DAEMON_PLATFORM_RUNTIME_EDGES` in - [`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts): + [`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts) + is now empty, so the classification table and its symbol-drift and staleness passes are deleted + and R76 bans the coupling outright — any production `src/daemon/**` import of the family or of a + root hub that reaches it is a violation. The categories and outcomes below remain the record of + how each edge was judged and retired): - **Composition-essential** — assembling neutral runtime implementations at the process root. - **Daemon-policy-essential** — request admission, session ownership, conflicts, locking, cancellation, teardown ordering, artifact publication, or response/event semantics. @@ -40,9 +46,10 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. behind a deeper runtime interface or adapter; only this category creates implementation work (child issues below). The gate rule **R76 `daemon-platform-runtime-inventory`** (in `scripts/layering/check.ts`) - fails the layering check on any unclassified edge, on symbol-set drift between a file and its - inventory entry, and on stale entries. Observed red against a planted unclassified import - before acceptance. + originally failed the layering check on any unclassified edge, on symbol-set drift between a + file and its inventory entry, and on stale entries; observed red against a planted unclassified + import before acceptance. Since the amendment it fails on any production daemon import of the + family or a hub that reaches it, in any static or dynamic form. 2. **Per-edge outcomes at `27a97ee619`.** Four edges are composition-essential and stay (`daemon-runtime.ts` → `platform-runtime.ts` and → `platform-runtime-host-diagnostics.ts`; @@ -70,9 +77,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. relaunch-target policy, `session-selector-dispatch.ts` consumes the app-bundle-identity resolver; both edges are daemon-policy-essential, and the resolver stays the one construction path for the open plan. - - **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the - selector seam those issues own; coordination was posted there rather than opening a second - selector producer. + - **#2273/#2274 (done)** — the `direct-ios-selector.ts` → `queryAppleRuntimeSelector` edge is + retired. The live selector read is now the `querySelector` runtime operation on the + request-bound owner (`SelectorObservationRuntimeOperations`, declared CONDITIONAL on the + captured touch and gesture uses), so the off-screen rescue (#1542) executes on the binding + the command was already admitted for; no second selector producer was added. 3. **Session state and store authority are measured as a symbol-level overlay and ratcheted on the handler-owned slice.** At `27a97ee619` the overlay is 112 `SessionState` shape edges @@ -129,8 +138,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. ## 4. Enforced by -- R76 `daemon-platform-runtime-inventory` and R75 `session-authority-overlay` in - `scripts/layering/check.ts` (both observed red against planted violations before acceptance). +- R76 `daemon-platform-runtime-inventory` — since the 2026-10-12 amendment a plain ban on the + coupling rather than a classification table (the inventory is empty and its machinery is + deleted) — and R75 `session-authority-overlay` in `scripts/layering/check.ts` (both observed red + against planted violations before acceptance). - R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice. - R65 for the concrete-platform-import ban this audit builds on. -- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 remain for the selector seam. +- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 landed with the `querySelector` + runtime operation (see §2.2). diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index d1ba2ed604..5a83dd20cf 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -14,9 +14,8 @@ whose production tree is `7dda0c2bf`'s; the harness lives only at that commit `resolveImportEdges`. Do not enforce a subset of them from a second, disagreeing import graph. - The model's implementation remains replaceable. Besides these eight rules, it must supply resolved edges with value, type-only and dynamic kinds to R9/R10, R19, R73–R76 and - `scripts/depgraph/`; named symbols and the binding residue of dynamic imports to R76; and raw - specifier sites to R11 and R13. Eleven other registry entries read source ASTs or text only, and - one reads paths only. + `scripts/depgraph/`; named symbols to R75; and raw specifier sites to R11 and R13. Eleven other + registry entries read source ASTs or text only, and one reads paths only. - Fix the demonstrated parser gap in #3293: `parseImports` misses TypeScript `import('x').T` type positions (9 file pairs). It already parses an OXC AST for dynamic imports but scans static imports with regexes; read both from that AST, `TSImportType` included, keeping symbol and diff --git a/docs/adr/README.md b/docs/adr/README.md index 93edf50e0b..de4f5fa894 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,7 +23,7 @@ | [0019 Request-Bound Platform Runtime](0019-request-bound-platform-runtime.md) | platform-package boundaries/composition, device discovery, runtime facts/facets, request binding, provider ownership, platform-shaped session resources, durable reattachment, daemon-handler migration | | [0020 Composable Recorded Fragments (Proposed)](0020-composable-recorded-fragments.md) | lifecycle-free recorded fragment capture/composition, entry guards, fragment-local addresses/digests, staleness, and native `.ad`/Maestro composition | | [0021 Simlock-Leased Devices Through Ordinary Local Runtimes](0021-host-simlock-managed-device-allocation.md) | driving a Simlock-leased simulator or emulator, why agent-device has no allocator integration, and the withdrawn in-process Host design | -| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the R76 edge classification inventory, R75 session-authority ratchet, entry-to-platform hop routes and roles | +| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the retired R76 edge classification now collapsed to a coupling ban, R75 session-authority ratchet, entry-to-platform hop routes and roles | | [0023 End-State Entry-to-Platform Hop Trace](0023-end-state-hop-trace.md) | the hop counting definition, the ordered `press`/Android and dual-arm `snapshot`/iOS chains, per-hop roles, and the deletion test behind the numbers ADR 0019 and ADR 0022 quote | | [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix | | [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, the `fold` pose control, and the Android emulator posture amendment | diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3d6981294d..55a2a13b35 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -144,24 +144,32 @@ export const perfRuntimePlanUses = Object.freeze([ perfProfileReportUse, ] as const); export const tapPointUse = defineUse({ required: ['tapPoint'] }); +// `querySelector` is the CONDITIONAL off-screen rescue read (#1542): the acting rows refuse an +// off-screen target from the required capture, and an owner that can answer with a live, +// tree-independent read gets one chance to overturn a false refusal. Both sides are complete: +// an owner without it refuses on the capture's verdict unchanged (ADR 0019 §9 parity), which is +// exactly the pre-rescue behavior every non-Apple owner always had. export const capturedTapUse = defineUse({ required: ['captureSnapshot', 'tapPoint'], preferred: ['tapRef'], - conditional: ['tapElementSelector'], + conditional: ['tapElementSelector', 'querySelector'], }); export const longPressPointUse = defineUse({ required: ['longPressPoint'] }); export const capturedLongPressUse = defineUse({ required: ['captureSnapshot', 'longPressPoint'], + conditional: ['querySelector'], }); export const hoverPointUse = defineUse({ required: ['hoverPoint'] }); export const capturedHoverUse = defineUse({ required: ['captureSnapshot', 'hoverPoint'], preferred: ['hoverRef'], + conditional: ['querySelector'], }); export const fillPointUse = defineUse({ required: ['fillPoint'] }); export const capturedFillUse = defineUse({ required: ['captureSnapshot', 'fillPoint'], preferred: ['fillRef'], + conditional: ['querySelector'], }); export const clickRuntimeUses = Object.freeze([tapPointUse, capturedTapUse] as const); @@ -223,23 +231,29 @@ export function resolveTouchRuntimePlan( * handler binds once — for the whole `swipe --count N` series as much as for one `gesture pinch`. * * Every tier binds snapshot capture with its action because capture is the fallback when the owner - * has no direct viewport read. `gestureViewport` remains preferred. + * has no direct viewport read. `gestureViewport` remains preferred. `querySelector` is the + * CONDITIONAL off-screen rescue read the gesture rows' endpoints resolve against (same parity + * argument as `capturedTapUse`): an owner without it refuses on the capture's verdict unchanged. */ const gesturePlanUse = defineUse({ required: ['performGesturePlan', 'captureSnapshot'], preferred: ['gestureViewport'], + conditional: ['querySelector'], }); const gestureDirectionalFlingUse = defineUse({ required: ['performDirectionalFlingPlan', 'captureSnapshot'], preferred: ['gestureViewport'], + conditional: ['querySelector'], }); const gestureMultiTouchUse = defineUse({ required: ['performMultiTouchGesturePlan', 'captureSnapshot'], preferred: ['gestureViewport'], + conditional: ['querySelector'], }); const gestureTargetAuthoredDragUse = defineUse({ required: ['performTargetAuthoredDrag', 'captureSnapshot'], preferred: ['gestureViewport'], + conditional: ['querySelector'], }); /** diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index fd42934a80..b4c3ccdeaf 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -221,9 +221,11 @@ export function createUnavailablePlatformRuntimeFacts( }), // The preferred text reading starts unavailable for every family, on the same sentinel as // capture: an owner that has a native reading declares it explicitly, and one that does not - // sends every text wait to the canonical tree. + // sends every text wait to the canonical tree. The live selector read starts unavailable for + // the same reason: an owner without it refuses on its bulk-tree verdict unchanged. ...selectorObservationRuntimeOperationFacts({ findText: frozen.snapshot, + querySelector: frozen.snapshot, }), ...viewportRuntimeOperationFacts({ setViewport: frozen.viewport }), ...focusRuntimeOperationFacts({ focus: frozen.focus }), diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 25bf9a1c7b..1239630cbb 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -65,6 +65,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'performTargetAuthoredDrag', 'prepareAppleRunner', 'prepareApplicationOpen', + 'querySelector', 'readAlert', 'readClipboard', 'readSetting', diff --git a/packages/contracts/src/selector-observation-runtime.ts b/packages/contracts/src/selector-observation-runtime.ts index 318d092bda..8cce121889 100644 --- a/packages/contracts/src/selector-observation-runtime.ts +++ b/packages/contracts/src/selector-observation-runtime.ts @@ -1,3 +1,5 @@ +import type { Rect } from '@agent-device/kernel/snapshot'; +import type { ElementSelectorKey } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SessionSurface } from './session-surface.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -10,6 +12,9 @@ type ObservationInput = Readonly<{ export type FindTextInput = ObservationInput & Readonly<{ text: string }>; +export type QuerySelectorInput = ObservationInput & + Readonly<{ selector: Readonly<{ key: ElementSelectorKey; value: string }> }>; + /** * Positive native observations are authoritative and preserve matches that an advertising owner's * bulk capture may omit. A negative observation means only "not proven by this owner"; the caller @@ -19,17 +24,41 @@ export type FindTextInput = ObservationInput & Readonly<{ text: string }>; export type SelectorObservationResult = Readonly<{ found: boolean }>; export type FindTextResult = SelectorObservationResult; +/** + * The live geometry the owner read for the matched node, projected to the two fields the + * off-screen rescue consumes. Owners without a node-scoped live read report the operation + * unavailable; the caller then refuses on its bulk-tree verdict unchanged. + */ +export type QuerySelectorNodeReading = Readonly<{ + rect: Rect; + hittable: boolean; +}>; + +export type QuerySelectorResult = SelectorObservationResult & + Readonly<{ + node?: QuerySelectorNodeReading; + }>; + export type SelectorObservationRuntimeOperations = Readonly<{ findText(input: FindTextInput): Promise; + querySelector(input: QuerySelectorInput): Promise; }>; export type FindTextRuntimeOperations = Pick; +export type QuerySelectorRuntimeOperations = Pick< + SelectorObservationRuntimeOperations, + 'querySelector' +>; export type SelectorObservationRuntimeOperationFacts = Readonly<{ findText: RuntimeOperationFact; + querySelector: RuntimeOperationFact; }>; export function selectorObservationRuntimeOperationFacts( input: SelectorObservationRuntimeOperationFacts, ): SelectorObservationRuntimeOperationFacts { - return Object.freeze({ findText: input.findText }); + return Object.freeze({ + findText: input.findText, + querySelector: input.querySelector, + }); } diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index fe691b14ed..cd5494dc89 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -299,9 +299,11 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ...screenshotRuntimeOperationFacts({ capture: device.kind === 'simulator' ? screenshotKindUnavailable : available, }), - // No native text reading: every text wait on this owner polls the canonical tree. + // No native text reading: every text wait on this owner polls the canonical tree. No live + // selector read either: the off-screen guard refuses on the capture's verdict unchanged. ...selectorObservationRuntimeOperationFacts({ findText: snapshotKindUnavailable, + querySelector: snapshotKindUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ...focusRuntimeOperationFacts({ focus: androidTouchFact(device) }), diff --git a/packages/platform-apple/src/__tests__/runtime-selector-query.test.ts b/packages/platform-apple/src/__tests__/runtime-selector-query.test.ts new file mode 100644 index 0000000000..d9c85203ba --- /dev/null +++ b/packages/platform-apple/src/__tests__/runtime-selector-query.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; + +const { queryAppleRunnerSelector } = vi.hoisted(() => ({ + queryAppleRunnerSelector: vi.fn(async (): Promise> => ({ + found: true, + })), +})); + +vi.mock('../core/runner-selector-query.ts', () => ({ queryAppleRunnerSelector })); + +import { bindAppleQuerySelectorRuntime } from '../runtime-selector-query.ts'; + +beforeEach(() => { + queryAppleRunnerSelector.mockReset(); + queryAppleRunnerSelector.mockResolvedValue({ found: true }); +}); + +const APP = 'com.example.app'; + +function bind(signal?: AbortSignal) { + return bindAppleQuerySelectorRuntime({ + device: IOS_SIMULATOR, + signal: signal ?? new AbortController().signal, + }); +} + +test('forwards the selector, app scope, and the full runner-execution projection', async () => { + await bind().querySelector({ + selector: { key: 'id', value: 'submit-order' }, + options: { appBundleId: APP }, + execution: { + requestId: 'req-9', + logPath: '/tmp/runner.log', + traceLogPath: '/tmp/trace.log', + verbose: true, + iosXctestrunFile: '/tmp/run.xctestrun', + }, + }); + + expect(queryAppleRunnerSelector).toHaveBeenCalledWith( + IOS_SIMULATOR, + { key: 'id', value: 'submit-order' }, + APP, + expect.objectContaining({ + requestId: 'req-9', + logPath: '/tmp/runner.log', + traceLogPath: '/tmp/trace.log', + verbose: true, + iosXctestrunFile: '/tmp/run.xctestrun', + }), + ); +}); + +test('refuses an already-aborted binding signal without querying the runner', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + bind(controller.signal).querySelector({ selector: { key: 'label', value: 'Go' } }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(queryAppleRunnerSelector).not.toHaveBeenCalled(); +}); + +test('projects the matched node to its rect and hittability', async () => { + queryAppleRunnerSelector.mockResolvedValue({ + found: true, + nodes: [{ index: 0, rect: { x: 1, y: 2, width: 3, height: 4 }, hittable: true }], + }); + + await expect(bind().querySelector({ selector: { key: 'label', value: 'Go' } })).resolves.toEqual({ + found: true, + node: { rect: { x: 1, y: 2, width: 3, height: 4 }, hittable: true }, + }); +}); + +test('a miss answers found:false with no node', async () => { + queryAppleRunnerSelector.mockResolvedValue({ found: false, nodes: [] }); + + await expect( + bind().querySelector({ selector: { key: 'label', value: 'Gone' } }), + ).resolves.toEqual({ found: false }); +}); + +test('a node without a usable rect answers found without a node (the rescue stays fail-closed)', async () => { + queryAppleRunnerSelector.mockResolvedValue({ found: true, nodes: [{ index: 0 }] }); + + await expect(bind().querySelector({ selector: { key: 'label', value: 'Go' } })).resolves.toEqual({ + found: true, + }); +}); diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index 90165916d2..0bd6ec01f7 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -58,6 +58,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< captureSnapshotWithCustomActions: 'runner', captureScreenshot: 'simulator', findText: 'runner', + querySelector: 'runner', readTextAtPoint: 'runner', // Every interaction and runner-driven capture. tapPoint: 'runner', diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 7c826e11ee..277672a80f 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -29,7 +29,6 @@ export { verifyLeaseRunnerPidIdentity, writeRunnerCacheMetadataForArtifacts, } from './core/runner-client.ts'; -export { queryAppleRunnerSelector } from './core/runner-selector-query.ts'; export async function cleanupRunnerLeasesForOwner( owner: Parameters<(typeof import('./core/runner-client.ts'))['cleanupRunnerLeasesForOwner']>[0], diff --git a/packages/platform-apple/src/runtime-selector-query.ts b/packages/platform-apple/src/runtime-selector-query.ts new file mode 100644 index 0000000000..5ff076e49c --- /dev/null +++ b/packages/platform-apple/src/runtime-selector-query.ts @@ -0,0 +1,69 @@ +import type { + QuerySelectorInput, + QuerySelectorNodeReading, + QuerySelectorResult, + SelectorObservationRuntimeOperations, +} from '@agent-device/contracts/selector-observation-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parseRect } from '@agent-device/kernel/record'; +import type { RunnerCallOptions } from '@agent-device/contracts/interactor-types'; + +type AppleSelectorQueryRequest = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; +}>; + +/** + * The runner's live, tree-independent node read. The daemon never asks the runner a question + * itself: an owner that exposes this conditional operation answers with the geometry the runner + * measured right now, which is what lets the shared off-screen guard (#1542) overturn a false + * refusal produced by a stale bulk tree. A refusal or a miss stays a miss — this read rescues, + * it never relaxes a genuine verdict, and it never mutates the device. + * + * The runner is scoped to an application when the request names one, exactly as the retired + * daemon-side probe scoped its own call; an unscoped query keeps the runner's own semantics. + */ +export function bindAppleQuerySelectorRuntime( + request: AppleSelectorQueryRequest, +): Pick { + return Object.freeze({ + querySelector: async (input: QuerySelectorInput): Promise => { + const signal = input.signal + ? AbortSignal.any([request.signal, input.signal]) + : request.signal; + signal.throwIfAborted(); + // Same projection bindAppleFindTextRuntime builds: the execution context is exactly the + // runner-call fields, with the composed signal replacing the caller's own. + const options: RunnerCallOptions = { ...input.execution, signal }; + // Deferred exactly like the retired daemon-side edge: the runner client binds its host at + // evaluation, so mechanics load on the first read, never with the runtime façade. + const { queryAppleRunnerSelector } = await import('./core/runner-selector-query.ts'); + const data = await queryAppleRunnerSelector( + request.device, + input.selector, + input.options?.appBundleId, + options, + ); + const node = readQuerySelectorNode(data); + return Object.freeze({ + found: data.found === true, + ...(node ? { node } : {}), + }); + }, + }); +} + +function readQuerySelectorNode( + data: Record, +): QuerySelectorNodeReading | undefined { + const nodes = data.nodes; + if (!Array.isArray(nodes)) return undefined; + const node = nodes[0]; + if (!node || typeof node !== 'object') return undefined; + const rect = parseRect((node as { rect?: unknown }).rect); + if (!rect) return undefined; + return Object.freeze({ + rect, + hittable: (node as { hittable?: unknown }).hittable === true, + }); +} diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 486ae66561..3c5f7df86f 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -47,6 +47,31 @@ const leaves = { watchos: appleDevice({ appleOs: 'watchos' }), } satisfies Record; +// #1542 wiring on the owner side: the CONDITIONAL live selector read must be advertised AND +// bound on every leaf the kind cell covers, and absent on the macOS desktop. Dropping +// `...selectorQueryOperations` from the binding (while the fact stays available) breaks the +// second expectation here; mis-declaring the fact breaks the first. +test.each([ + ['iOS simulator', leaves.ios, true], + ['iOS physical device', appleDevice({ kind: 'device' }), true], + ['macOS host', leaves.macos, false], +] as const)( + 'binds the querySelector read on the %s leaf per its fact', + async (_name, device, advertised) => { + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.querySelector.available).toBe(advertised); + expect(binding.operations.querySelector).toBeTypeOf(advertised ? 'function' : 'undefined'); + }, +); + test('tvOS audio capture availability follows the exact host-owned runtime fact', async () => { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ device: leaves.tvos, diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 81fc7b9713..a536d89d22 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -31,7 +31,10 @@ import { bindLocalScreenshotInteractor, screenshotRuntimeOperationFacts, } from '@agent-device/contracts/screenshot-runtime'; -import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; +import { + selectorObservationRuntimeOperationFacts, + type SelectorObservationRuntimeOperations, +} from '@agent-device/contracts/selector-observation-runtime'; import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; import { bindLocalTypeTextInteractor, @@ -71,6 +74,7 @@ import { appleNavigationFacts, createAppleNavigationOperations } from './navigat import { appleSystemFacts, createAppleSystemOperations } from './system/runtime.ts'; import { appleFoldableFacts, createAppleFoldableOperations } from './foldable/runtime.ts'; import { bindAppleFindTextRuntime, bindAppleSnapshotRuntime } from './runtime-snapshot.ts'; +import { bindAppleQuerySelectorRuntime } from './runtime-selector-query.ts'; import type { MacOsAppBackend } from '@agent-device/contracts/session'; import { hostMacOsAppBackend } from './os/macos/app-backend.ts'; import { macOsNativeBackendFacts } from './os/macos/native-backend-facts.ts'; @@ -285,6 +289,9 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ...screenshotRuntimeOperationFacts({ capture: appleScreenshotFact(device) }), ...selectorObservationRuntimeOperationFacts({ findText: appleSnapshotFact(device), + // The live selector read rides the runner, so it shares the exact iOS-family kind cell + // the fused selector tap declares — not the macOS desktop or watchOS. + querySelector: isIosFamily(device) ? appleFocusFact(device) : unavailable, }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ...focusRuntimeOperationFacts({ focus: appleFocusFact(device) }), @@ -336,8 +343,18 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR resolveInteractor: host.localInteractors.resolve, }), ); + // Typed on its own for the same union-budget reason as `appStateOperations`. + const selectorQueryOperations: Partial = whenAdmitted( + facts.operations.querySelector, + () => + bindAppleQuerySelectorRuntime({ + device: request.device, + signal: request.scope.signal, + }), + ); const operations: DeviceBinding['operations'] = { ...appStateOperations, + ...selectorQueryOperations, ...logs.operations, ...createAppleAppDeploymentOperations({ host, diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 2e08355b75..f24fc1d314 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -204,9 +204,11 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ? available : screenshotKindUnavailable, }), - // No native text reading: every text wait on this owner polls the canonical tree. + // No native text reading: every text wait on this owner polls the canonical tree. No live + // selector read either: the off-screen guard refuses on the capture's verdict unchanged. ...selectorObservationRuntimeOperationFacts({ findText: snapshotKindUnavailable, + querySelector: snapshotKindUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ...focusRuntimeOperationFacts({ focus: harmonyFocusFact(device) }), diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index aafbcdde49..fe205cfd65 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -362,9 +362,11 @@ function webRuntimeFacts( customActions: snapshotCustomActionsUnavailable, withoutActiveApp: browserDevice, }), - // No native text reading: every text wait on this owner polls the canonical tree. + // No native text reading: every text wait on this owner polls the canonical tree. No live + // selector read either: the off-screen guard refuses on the capture's verdict unchanged. ...selectorObservationRuntimeOperationFacts({ findText: openTargetKindUnavailable, + querySelector: openTargetKindUnavailable, }), ...screenshotRuntimeOperationFacts({ capture: browserDevice }), ...focusRuntimeOperationFacts({ focus: browserDevice }), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index c7b6357567..63bba7fb8e 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -170,9 +170,11 @@ export function limrunAppLogFacts( }), ...screenshotRuntimeOperationFacts({ capture: available }), // Provider ownership is authoritative: no native text reading is exposed, so text waits - // on a Limrun-owned device poll the canonical tree rather than borrowing Apple's. + // on a Limrun-owned device poll the canonical tree rather than borrowing Apple's. The same + // ownership answer covers the live selector read: no rescue hook on a provider device. ...selectorObservationRuntimeOperationFacts({ findText: customSnapshotUnavailable, + querySelector: customSnapshotUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), // Focus rides the same provider interactor the captures do, and a live-session Limrun @@ -238,6 +240,7 @@ export function limrunAppLogRecoveryFacts( ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), ...selectorObservationRuntimeOperationFacts({ findText: liveSessionUnavailable, + querySelector: liveSessionUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), ...limrunInteractionOperationFacts(device, liveSessionUnavailable), diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 06a06ae59b..d60ab64d9c 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -129,10 +129,7 @@ import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownersh import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts'; -import { - checkDaemonPlatformRuntimeInventory, - DAEMON_PLATFORM_RUNTIME_EDGES, -} from './daemon-platform-runtime-inventory.ts'; +import { checkDaemonPlatformRuntimeInventory } from './daemon-platform-runtime-inventory.ts'; import { checkDaemonClientEntry } from './daemon-client-entry.ts'; import { checkDaemonLayers, readDaemonLayerManifest } from './daemon-layers.ts'; import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts'; @@ -431,8 +428,8 @@ function report( `runtime facts remain the only device-command admission authority and daemon code cannot ` + `manufacture narrowed runtime proof (R66); R65 keeps production src/daemon free of ` + `concrete platform imports in every executable and type-only form; ` + - `${DAEMON_PLATFORM_RUNTIME_EDGES.length} daemon-to-root platform-runtime edges hold ` + - `their #2278 classification (R76); and the handler-owned SessionState/SessionStore ` + + `no production daemon file reaches the root platform-runtime family or a hub that ` + + `does (R76); and the handler-owned SessionState/SessionStore ` + `authority overlay holds at or under the merge-base (R75, ` + `${handlerOwnedShapeFiles} shape / ${handlerOwnedAuthorityFiles} authority files); and ` + `every production daemon file is owned by one layer with no upward static edges across ` + diff --git a/scripts/layering/daemon-layer-manifest.json b/scripts/layering/daemon-layer-manifest.json index 09f744d822..69c5e1af97 100644 --- a/scripts/layering/daemon-layer-manifest.json +++ b/scripts/layering/daemon-layer-manifest.json @@ -1,6 +1,5 @@ { "daemon-core": [ - "src/daemon/apple-runner-options.ts", "src/daemon/capture-disclosure.ts", "src/daemon/config.ts", "src/daemon/context.ts", diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index 48503a4ec4..b0c04338dd 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -3,17 +3,15 @@ import { test } from 'node:test'; import { resolveImportEdges, type LayeringViolation } from './model.ts'; import { checkDaemonPlatformRuntimeInventory, - DAEMON_PLATFORM_RUNTIME_EDGES, DAEMON_PLATFORM_RUNTIME_RULE, isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -// The classified pair these fixtures plant is the direct-iOS selector query: the one edge the -// PR-6 composition extraction left between the daemon and the root platform-runtime family, and -// the shortest live one whose symbols the rule still classifies. -const CLASSIFIED_TARGET = 'src/platform-runtime-apple-resources.ts'; -const CLASSIFIED_STUB = 'export const queryAppleRuntimeSelector = () => ({});\n'; -const CLASSIFIED_IMPORTER = 'src/daemon/direct-ios-selector.ts'; +// The pair these fixtures plant is the direct-iOS selector query: the last edge the #2278 +// inventory carried, retired by #3430. Under the ban it is a violation like any other. +const RETIRED_TARGET = 'src/platform-runtime-apple-resources.ts'; +const RETIRED_STUB = 'export const queryAppleRuntimeSelector = () => ({});\n'; +const RETIRED_IMPORTER = 'src/daemon/direct-ios-selector.ts'; function violations(sources: Record): LayeringViolation[] { return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); @@ -23,14 +21,21 @@ function edgeViolations(sources: Record, file: string): Layering return violations(sources).filter((violation) => violation.file === file); } -test('R76 accepts a classified edge with the exact recorded symbols', () => { +test('R76 rejects the direct-iOS selector edge the retired inventory classified', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: + [RETIRED_TARGET]: RETIRED_STUB, + [RETIRED_IMPORTER]: "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + 'void queryAppleRuntimeSelector;\n', }; - assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); + const found = edgeViolations(sources, RETIRED_IMPORTER); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /daemon coupling to root platform mechanics is banned/); + assert.match( + found[0]!.message, + /src\/daemon\/direct-ios-selector\.ts -> src\/platform-runtime-apple-resources\.ts/, + ); }); for (const [file, target, symbol] of [ @@ -47,27 +52,11 @@ for (const [file, target, symbol] of [ const found = edgeViolations(sources, importer); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /unclassified daemon coupling|classified symbols drifted/); + assert.match(found[0]!.message, /daemon coupling to root platform mechanics is banned/); }); } -test('R76 reports every classified edge missing from the tree as stale, not the other way around', () => { - const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: - "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + - 'void queryAppleRuntimeSelector;\n', - }; - const stale = violations(sources).filter( - (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', - ); - assert.equal(stale.length, DAEMON_PLATFORM_RUNTIME_EDGES.length - 1); - assert.ok(stale.every((violation) => violation.message.includes('stale classified edge'))); - const classifiedStale = stale.find((violation) => violation.message.includes(CLASSIFIED_TARGET)); - assert.equal(classifiedStale, undefined); -}); - -test('R76 rejects an unclassified edge with the pair and its line', () => { +test('R76 rejects an edge with the pair and its line', () => { const sources = { 'src/platform-runtime-android-tool-host.ts': 'export function createAndroidToolHost() {}\n', 'src/daemon/fixture.ts': @@ -78,142 +67,50 @@ test('R76 rejects an unclassified edge with the pair and its line', () => { assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.equal(found[0]!.line, 1); - assert.match(found[0]!.message, /unclassified daemon coupling to platform mechanics/); + assert.match(found[0]!.message, /daemon coupling to root platform mechanics is banned/); assert.match( found[0]!.message, /src\/daemon\/fixture\.ts -> src\/platform-runtime-android-tool-host\.ts/, ); }); -test('R76 rejects new symbols on a classified edge', () => { +test('R76 reports an import and re-export of one pair as a single violation', () => { const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraGatewayKnob() {}\n', - [CLASSIFIED_IMPORTER]: - "import { queryAppleRuntimeSelector, extraGatewayKnob } from '../platform-runtime-apple-resources.ts';\n" + - 'void [queryAppleRuntimeSelector, extraGatewayKnob];\n', - }; - const found = edgeViolations(sources, CLASSIFIED_IMPORTER); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /extraGatewayKnob, queryAppleRuntimeSelector/); -}); - -test('R76 rejects a selector fast path assembled from an expanded query edge', () => { - const sources = { - 'src/platform-runtime-apple-resources.ts': - 'export const queryAppleRuntimeSelector = () => ({});\n' + - 'export const queryProviderRuntimeSelector = () => ({});\n', - 'src/daemon/direct-ios-selector.ts': - "import {\n queryAppleRuntimeSelector,\n queryProviderRuntimeSelector,\n} from '../platform-runtime-apple-resources.ts';\n" + - 'void [queryAppleRuntimeSelector, queryProviderRuntimeSelector];\n', + [RETIRED_TARGET]: RETIRED_STUB, + [RETIRED_IMPORTER]: + "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + "export { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + 'void queryAppleRuntimeSelector;\n', }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /queryAppleRuntimeSelector, queryProviderRuntimeSelector/); + assert.equal(edgeViolations(sources, RETIRED_IMPORTER).length, 1); }); -test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { +test('R76 rejects a destructured dynamic import of the root platform-runtime family', () => { const sources = { - 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', - 'src/daemon/direct-ios-selector.ts': + [RETIRED_TARGET]: RETIRED_STUB, + [RETIRED_IMPORTER]: "const { queryAppleRuntimeSelector } = await import('../platform-runtime-apple-resources.ts');\n" + 'void queryAppleRuntimeSelector;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'), []); + assert.equal(edgeViolations(sources, RETIRED_IMPORTER).length, 1); }); -test('R76 rejects an expanded destructured dynamic import on a classified edge', () => { +test('R76 rejects a dynamic namespace import of a root platform-runtime module', () => { const sources = { - 'src/platform-runtime-apple-resources.ts': - 'export const queryAppleRuntimeSelector = {};\n' + - 'export const extraLifecycleParticipant = {};\n', - 'src/daemon/direct-ios-selector.ts': - "const { queryAppleRuntimeSelector, extraLifecycleParticipant } = await import('../platform-runtime-apple-resources.ts');\n" + - 'void [queryAppleRuntimeSelector, extraLifecycleParticipant];\n', + [RETIRED_TARGET]: RETIRED_STUB, + [RETIRED_IMPORTER]: + "const appleResources = await import('../platform-runtime-apple-resources.ts');\n" + + 'void appleResources;\n', }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); + const found = edgeViolations(sources, RETIRED_IMPORTER); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /extraLifecycleParticipant, queryAppleRuntimeSelector/); -}); - -test('R76 rejects a rest binding next to a recorded dynamic-import binding', () => { - const sources = { - 'src/platform-runtime-apple-resources.ts': - 'export const queryAppleRuntimeSelector = {};\n' + - 'export const sweepLifecycleParticipants = {};\n', - 'src/daemon/direct-ios-selector.ts': - "const { queryAppleRuntimeSelector, ...lifecycleModule } = await import('../platform-runtime-apple-resources.ts');\n" + - 'void [queryAppleRuntimeSelector, lifecycleModule];\n', - }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /unnameable dynamic-import binding/); assert.match( found[0]!.message, /src\/daemon\/direct-ios-selector\.ts -> src\/platform-runtime-apple-resources\.ts/, ); }); -test('R76 rejects a computed destructure key on a classified dynamic import', () => { - const sources = { - 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', - 'src/daemon/direct-ios-selector.ts': - "const ownersName = 'queryAppleRuntimeSelector';\n" + - 'const { [ownersName]: owners } = await import("../platform-runtime-apple-resources.ts");\n' + - 'void owners;\n', - }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); - assert.equal(found.length, 1); - assert.match(found[0]!.message, /unnameable dynamic-import binding/); -}); - -test('R76 rejects a namespace-form dynamic import that hides the recorded bindings', () => { - const sources = { - 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', - 'src/daemon/direct-ios-selector.ts': - "const mod = await import('../platform-runtime-apple-resources.ts');\nvoid mod;\n", - }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /open-ended dynamic import/); -}); - -test('R76 rejects a namespace import alongside the recorded named binding on the same pair', () => { - const sources = { - 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', - 'src/daemon/direct-ios-selector.ts': - "const { queryAppleRuntimeSelector } = await import('../platform-runtime-apple-resources.ts');\n" + - "const lifecycleModule = await import('../platform-runtime-apple-resources.ts');\n" + - 'void [queryAppleRuntimeSelector, lifecycleModule];\n', - }; - const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /open-ended dynamic import/); - assert.match( - found[0]!.message, - /src\/daemon\/direct-ios-selector\.ts -> src\/platform-runtime-apple-resources\.ts/, - ); -}); - -test('R76 treats the import and re-export of one classified pair as one entry', () => { - const sources = { - [CLASSIFIED_TARGET]: CLASSIFIED_STUB, - [CLASSIFIED_IMPORTER]: - "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + - "export { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + - 'void queryAppleRuntimeSelector;\n', - }; - assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); -}); - test('R76 ignores test-shaped and non-daemon importers', () => { const sources = { 'src/platform-runtime-android-tool-host.ts': 'export function createAndroidToolHost() {}\n', @@ -224,12 +121,7 @@ test('R76 ignores test-shaped and non-daemon importers', () => { "import { createAndroidToolHost } from './platform-runtime-android-tool-host.ts';\n" + 'void createAndroidToolHost;\n', }; - assert.deepEqual( - violations(sources).filter( - (violation) => violation.file !== 'scripts/layering/daemon-platform-runtime-inventory.ts', - ), - [], - ); + assert.deepEqual(violations(sources), []); }); test('R76 ignores retired-zone targets, which R65 owns', () => { @@ -265,7 +157,7 @@ test('R76 catches a daemon import of a root hub that reaches the platform family assert.match(found[0]!.message, /src\/daemon\/fixture\.ts -> src\/hub-runtime\.ts/); }); -test('R76 does not turn an intra-daemon hop into a classified hub edge', () => { +test('R76 does not turn an intra-daemon hop into a hub edge', () => { const sources = { 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, 'src/daemon/dispatch.ts': @@ -273,4 +165,8 @@ test('R76 does not turn an intra-daemon hop into a classified hub edge', () => { 'src/daemon/handler.ts': "import { dispatch } from './dispatch.ts';\nvoid dispatch;\n", }; assert.deepEqual(edgeViolations(sources, 'src/daemon/handler.ts'), []); + assert.equal( + violations(sources).filter((violation) => violation.file === 'src/daemon/dispatch.ts').length, + 1, + ); }); diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 55debe1871..60a7e2d55e 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -3,41 +3,8 @@ import { isProductionSourceFile } from './tracked-sources.ts'; import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; import { ALL_EDGES, importGraphFromResolvedEdges } from '../depgraph/import-graph.ts'; -// The classified inventory of every production daemon import that reaches the root platform-runtime -// composition modules (#2278, ADR 0022, #2542). R65 already bans daemon imports of concrete platform -// packages and the retired src/platforms zone; the composition layer is the one place the daemon may -// still touch, and this table is the classification of every edge that reaches it — either directly -// into the src/platform-runtime*.ts family, or into any module outside the daemon zone that imports -// that family itself, statically or dynamically. A root hub such as src/provider-device-runtime.ts is -// named by neither file pattern nor zone, so a daemon import of it used to be invisible here while -// carrying the same platform mechanics. An edge is unclassifiable until it is recorded here with a -// rationale, and a recorded edge that no longer exists is stale — both fail, so the inventory and the -// tree cannot drift apart in either direction. - export const DAEMON_PLATFORM_RUNTIME_RULE = 'R76 daemon-platform-runtime-inventory'; -export type DaemonPlatformRuntimeClassification = - | 'composition-essential' - | 'daemon-policy-essential' - | 'leaked-platform-mechanics'; - -export type DaemonPlatformRuntimeEdge = Readonly<{ - file: string; - target: string; - /** - * Exact named symbols across every edge of the pair; empty for static side-effect imports (a - * destructured dynamic import records its bindings, so widening the destructure is a drift, not - * a silent expansion). Unnameable dynamic-import forms cannot be recorded here, and R76 rejects - * the edge: a rest or computed destructure binding, or a namespace/side-effect import() call — - * both expose exports beyond this list. - */ - symbols: readonly string[]; - classification: DaemonPlatformRuntimeClassification; - rationale: string; - /** The seam or child issue that deepens a leaked-platform-mechanics edge. */ - deepenedBy?: string; -}>; - /** The root platform-runtime composition family: src/platform-runtime.ts and src/platform-runtime-*.ts. */ export function isRootPlatformRuntimeTarget(target: string): boolean { return /^src\/platform-runtime(?:\.ts|-[a-z0-9-]+\.ts)$/.test(target); @@ -48,8 +15,8 @@ export function isRootPlatformRuntimeTarget(target: string): boolean { * platform-runtime family: the family itself, plus anything that imports it, transitively, over * static and dynamic edges alike. Computed from the tree rather than patterned so a root hub that * carries platform mechanics without naming them gains no immunity from its filename, and so a - * dynamic edge out of the daemon zone is caught like a static one instead of escaping the - * inventory (#2542). + * dynamic edge out of the daemon zone is caught like a static one instead of escaping the rule + * (#2542). */ export function computePlatformMechanicsHubs( edges: readonly ResolvedImportEdge[], @@ -72,152 +39,43 @@ export function computePlatformMechanicsHubs( } /** - * The current classified edges from the #2278 audit, ratcheted as owning interfaces land. - */ -export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] = [ - { - file: 'src/daemon/direct-ios-selector.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['queryAppleRuntimeSelector'], - classification: 'leaked-platform-mechanics', - rationale: - 'the direct-iOS fast path queries the Apple runner selector mechanics directly; the ' + - 'selector-producer seam owned by #2273/#2274 is the accepted deepening, and this ' + - 'audit deliberately adds no second selector producer.', - deepenedBy: '#2273, #2274', - }, -] as const; - -function keyOf(file: string, target: string): string { - return `${file} -> ${target}`; -} - -function sorted(symbols: readonly string[]): string[] { - return [...symbols].sort(); -} - -/** - * Catches: unclassified daemon-to-platform-mechanics coupling regrowing — a new edge (or a - * new symbol on an existing edge) that the #2278 audit never classified, the mirror failure, a - * classified edge that no longer exists and would silently admit its return, and dynamic - * imports whose binding set the inventory cannot name: a rest or computed destructure binding, - * or a namespace/side-effect import() call exposed alongside (or instead of) the named ones. - * Reaching the platform through a root hub is caught the same way as reaching it directly, and a - * dynamic edge is classified rather than skipped, so the direction the ranked spine cannot see is - * at least named here (#2542). - * Evidence: #2278 measured 14 production edges in 9 daemon files at origin/main 6e22e266d7; - * this table is that measurement, classified per ADR 0022, plus the 3 edges the hub widening and - * dynamic-edge pass made visible at #2541 (2 provider-runtime hubs, 1 dynamic interactor lookup - * that #2555 has since composed away). The daemon-runtime composition edges this table carried - * through #3402 moved to the process root with the PR-6 extraction (`src/daemon-composition.ts` - * is outside the daemon zone, so R76 no longer sees them); only the leaked selector-query edge - * below remains. + * Catches: any production daemon import of the root platform-runtime family, directly or through a + * root hub that reaches it, in any static or dynamic form. The #2278 audit classified every edge + * that was allowed to exist; the inventory is now empty, so the allowance is retired and every + * such edge is a violation naming the pair and its line. Reaching the platform through a root + * hub counts the same as reaching it directly, and no dynamic-import form is exempt. + * Evidence: #2278's 14 classified edges were all retired — the composition edges by #3431, moved + * to the process-root composition module outside the daemon zone, and the leaked selector-query + * edge by #3430, routed through the request-bound runtime binding. The rule then collapsed to + * this ban per its own kill criterion, deleting the classification table and its drift and + * staleness passes. * Cost: attributed to the R76 rule registration in check.ts; not a standalone CI job. - * Kill criterion: the daemon reaches the platform only through the gateway and declared - * contract capabilities (the inventory empty), or a maintainer decision retires the - * classification requirement. + * Kill criterion: the daemon moves into its own workspace package, whose package boundary then + * forbids these imports structurally. */ export function checkDaemonPlatformRuntimeInventory( edges: readonly ResolvedImportEdge[], ): LayeringViolation[] { const hubs = computePlatformMechanicsHubs(edges); - const actual = new Map< - string, - { line: number; symbols: Set; residue: boolean; openEnded: boolean } - >(); + const violations: LayeringViolation[] = []; + const reported = new Set(); for (const edge of edges) { if (!edge.file.startsWith('src/daemon/')) continue; if (!isProductionSourceFile(edge.file)) continue; if (!hubs.has(edge.target)) continue; - const key = keyOf(edge.file, edge.target); - const entry = actual.get(key) ?? { - line: edge.line, - symbols: new Set(), - residue: false, - openEnded: false, - }; - for (const symbol of edge.symbols) entry.symbols.add(symbol); - entry.residue = entry.residue || edge.bindingResidue; - // Validated per edge before the per-pair union: a dynamic import that names no binding - // exposes the whole module namespace, which sibling named edges of the same pair would - // otherwise mask inside the union. - entry.openEnded = - entry.openEnded || (edge.dynamic && !edge.bindingResidue && edge.symbols.length === 0); - actual.set(key, entry); - } - - const violations: LayeringViolation[] = []; - const seen = new Set(); - - for (const [key, entry] of actual) { - const declaration = DAEMON_PLATFORM_RUNTIME_EDGES.find( - (candidate) => keyOf(candidate.file, candidate.target) === key, - ); - if (declaration === undefined) { - violations.push({ - rule: DAEMON_PLATFORM_RUNTIME_RULE, - file: key.split(' -> ')[0]!, - line: entry.line, - message: - `unclassified daemon coupling to platform mechanics: ${key}. Classify it in ` + - `DAEMON_PLATFORM_RUNTIME_EDGES (${DAEMON_PLATFORM_RUNTIME_RULE}) with its rationale, ` + - `or remove the coupling.`, - }); - continue; - } - seen.add(key); - if (entry.residue) { - violations.push({ - rule: DAEMON_PLATFORM_RUNTIME_RULE, - file: key.split(' -> ')[0]!, - line: entry.line, - message: - `unnameable dynamic-import binding for ${key}: a rest or computed destructure exposes ` + - `bindings the inventory cannot name. Destructure every binding explicitly and record it ` + - `in DAEMON_PLATFORM_RUNTIME_EDGES (${DAEMON_PLATFORM_RUNTIME_RULE}), or remove the coupling.`, - }); - continue; - } - if (entry.openEnded) { - violations.push({ - rule: DAEMON_PLATFORM_RUNTIME_RULE, - file: key.split(' -> ')[0]!, - line: entry.line, - message: - `open-ended dynamic import for ${key}: a namespace or side-effect import() exposes the ` + - `whole module, which the inventory cannot name symbol by symbol. Destructure every ` + - `binding explicitly and record it in DAEMON_PLATFORM_RUNTIME_EDGES ` + - `(${DAEMON_PLATFORM_RUNTIME_RULE}), or remove the coupling.`, - }); - continue; - } - const expected = sorted(declaration.symbols); - const measured = sorted([...entry.symbols]); - if (expected.join('\u0000') !== measured.join('\u0000')) { - violations.push({ - rule: DAEMON_PLATFORM_RUNTIME_RULE, - file: key.split(' -> ')[0]!, - line: entry.line, - message: - `classified symbols drifted for ${key}: the tree imports ${measured.join(', ') || '(none)'} ` + - `but the inventory records ${expected.join(', ') || '(none)'}. Update the inventory ` + - `entry in the same change, or remove the added coupling.`, - }); - } - } - - for (const declaration of DAEMON_PLATFORM_RUNTIME_EDGES) { - const key = keyOf(declaration.file, declaration.target); - if (actual.has(key) || seen.has(key)) continue; + const key = `${edge.file} -> ${edge.target}`; + if (reported.has(key)) continue; + reported.add(key); violations.push({ rule: DAEMON_PLATFORM_RUNTIME_RULE, - file: 'scripts/layering/daemon-platform-runtime-inventory.ts', - line: 1, + file: edge.file, + line: edge.line, message: - `stale classified edge: ${key} no longer exists. Remove the entry so the coupling ` + - `cannot return unclassified.`, + `daemon coupling to root platform mechanics is banned: ${key}. The #2278 ` + + `classification inventory is retired and empty (${DAEMON_PLATFORM_RUNTIME_RULE}): ` + + `reach the platform through the gateway or a declared contract capability, or move the ` + + `responsibility to the process root.`, }); } - return violations; } diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 350630632d..0c958d0b59 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -48,6 +48,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre ...unavailableShutdownOperationFacts, ...screenshotRuntimeOperationFacts({ capture: unavailable }), findText: unavailable, + querySelector: unavailable, setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, diff --git a/src/daemon/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/__tests__/interaction-get-runtime-fixture.ts index 4faf4310f1..030fa844f4 100644 --- a/src/daemon/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/__tests__/interaction-get-runtime-fixture.ts @@ -25,6 +25,10 @@ import { type TapRefInput, HOVER_UNAVAILABLE_HINT, } from '@agent-device/contracts/touch-runtime'; +import type { + QuerySelectorInput, + QuerySelectorResult, +} from '@agent-device/contracts/selector-observation-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonPlatformServices } from '../platform-services.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; @@ -90,6 +94,14 @@ const mockFillRef = vi.fn(async (input: FillRefInput) => ({ })); export const mockTapElementSelector = vi.fn(async (_input: TapElementSelectorInput) => ({})); +/** + * The CONDITIONAL live selector read (#1542 rescue). Off by default so only the tests that pin + * the rescue's wiring flip it; when advertised, the binding answers through this mock. + */ +export const mockQuerySelector = vi.fn( + async (_input: QuerySelectorInput): Promise => ({ found: false }), +); + /** * Flip to model an exact owner cell: no live element read (web, HarmonyOS, provider), no capture * at all (the watchOS sentinel, an inactive provider), which refuses admission outright, or no @@ -101,6 +113,8 @@ export const elementReadFixtureState = { focusPointAvailable: true, typeTextAvailable: true, tapElementSelectorAvailable: true, + /** Off by default: only the tests pinning the #1542 rescue wiring advertise the live read. */ + querySelectorAvailable: false, }; export function resetGetRuntimeFixture(): void { @@ -143,6 +157,9 @@ export function resetGetRuntimeFixture(): void { elementReadFixtureState.focusPointAvailable = true; elementReadFixtureState.typeTextAvailable = true; elementReadFixtureState.tapElementSelectorAvailable = true; + elementReadFixtureState.querySelectorAvailable = false; + mockQuerySelector.mockReset(); + mockQuerySelector.mockResolvedValue({ found: false }); } const available = Object.freeze({ available: true } as const); @@ -186,6 +203,7 @@ function fixtureTouchFacts(device: DeviceInfo) { tapElementSelector: elementReadFixtureState.tapElementSelectorAvailable ? available : unavailable, + querySelector: elementReadFixtureState.querySelectorAvailable ? available : unavailable, }; } @@ -227,6 +245,9 @@ const mockBindElementReadRuntime: BindDeviceRuntime = vi.fn(async (device: Devic ...(elementReadFixtureState.tapElementSelectorAvailable ? { tapElementSelector: mockTapElementSelector } : {}), + ...(elementReadFixtureState.querySelectorAvailable + ? { querySelector: mockQuerySelector } + : {}), }), [Symbol.asyncDispose]: async () => undefined, }) as DeviceBinding; diff --git a/src/daemon/__tests__/offscreen-target-probe.test.ts b/src/daemon/__tests__/offscreen-target-probe.test.ts index 9afc5c4abd..2959b140f3 100644 --- a/src/daemon/__tests__/offscreen-target-probe.test.ts +++ b/src/daemon/__tests__/offscreen-target-probe.test.ts @@ -1,132 +1,129 @@ -import { beforeEach, test, vi } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; +import type { + QuerySelectorInput, + QuerySelectorResult, +} from '@agent-device/contracts/selector-observation-runtime'; import type { SessionState } from '../session-state.ts'; -const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ - mockRunAppleRunnerCommand: vi.fn(), -})); - import { confirmIosOffscreenTargetVisible } from '../offscreen-target-probe.ts'; -beforeEach(() => { - mockRunAppleRunnerCommand.mockReset(); -}); - function makeSession(): SessionState { return { name: 'default', device: IOS_SIMULATOR, createdAt: Date.now(), actions: [] }; } -async function withRunner(operation: () => Promise): Promise { - return await withAppleRunnerProvider( - mockRunAppleRunnerCommand, - { deviceId: IOS_SIMULATOR.id }, - operation, - ); +type ProbeQuery = (input: QuerySelectorInput) => Promise; + +function probeWith(querySelector: ProbeQuery, node: { identifier?: string; label?: string }) { + return confirmIosOffscreenTargetVisible({ + session: makeSession(), + node, + rootViewport: ROOT_VIEWPORT, + querySelector, + execution: {}, + }); } const ROOT_VIEWPORT = { x: 0, y: 0, width: 402, height: 874 }; -test('confirmIosOffscreenTargetVisible: returns the live rect when the runner confirms hittable + inside the viewport', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - nodes: [{ index: 0, rect: { x: 126, y: 136, width: 75, height: 38 }, hittable: true }], - }); +function found(rect: { x: number; y: number; width: number; height: number }, hittable: boolean) { + return async (): Promise => ({ found: true, node: { rect, hittable } }); +} - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: { identifier: 'shipping-pickup' }, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), - ); +test('confirmIosOffscreenTargetVisible: returns the live rect when the owner confirms hittable + inside the viewport', async () => { + const rect = await probeWith(found({ x: 126, y: 136, width: 75, height: 38 }, true), { + identifier: 'shipping-pickup', + }); assert.deepEqual(rect, { x: 126, y: 136, width: 75, height: 38 }); - assert.equal(mockRunAppleRunnerCommand.mock.calls[0]?.[1].selectorKey, 'id'); - assert.equal(mockRunAppleRunnerCommand.mock.calls[0]?.[1].selectorValue, 'shipping-pickup'); }); -test('confirmIosOffscreenTargetVisible: null when the node has no usable id/label (never calls the runner)', async () => { - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: {}, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), - ); +test('confirmIosOffscreenTargetVisible: forwards the derived id/label selector and the session app scope', async () => { + const querySelector = vi.fn(found({ x: 1, y: 2, width: 3, height: 4 }, true)); - assert.equal(rect, null); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); + await confirmIosOffscreenTargetVisible({ + session: { ...makeSession(), appBundleId: 'com.example.demo' }, + node: { label: 'Pickup' }, + rootViewport: ROOT_VIEWPORT, + querySelector, + execution: { requestId: 'req-1' }, + }); + + assert.deepEqual(querySelector.mock.calls[0]?.[0], { + selector: { key: 'label', value: 'Pickup' }, + options: { appBundleId: 'com.example.demo' }, + execution: { requestId: 'req-1' }, + }); }); -test('confirmIosOffscreenTargetVisible: null when the runner reports not found', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ found: false, nodes: [] }); +test('confirmIosOffscreenTargetVisible: a session without an app sends no options bag', async () => { + const querySelector = vi.fn(found({ x: 1, y: 2, width: 3, height: 4 }, true)); - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: { label: 'Pickup' }, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), - ); + await confirmIosOffscreenTargetVisible({ + session: makeSession(), + node: { label: 'Pickup' }, + rootViewport: ROOT_VIEWPORT, + querySelector, + execution: {}, + }); + + assert.equal('options' in (querySelector.mock.calls[0]?.[0] ?? {}), false); +}); + +test('confirmIosOffscreenTargetVisible: null when the node has no usable id/label (never calls the operation)', async () => { + const querySelector = vi.fn(found({ x: 1, y: 2, width: 3, height: 4 }, true)); + + const rect = await confirmIosOffscreenTargetVisible({ + session: makeSession(), + node: {}, + rootViewport: ROOT_VIEWPORT, + querySelector, + execution: {}, + }); assert.equal(rect, null); + assert.equal(querySelector.mock.calls.length, 0); }); -test('confirmIosOffscreenTargetVisible: null on an ambiguous match / any runner error (fail closed)', async () => { - mockRunAppleRunnerCommand.mockRejectedValue( - new AppError('AMBIGUOUS_MATCH', 'selector matched multiple elements'), - ); +test('confirmIosOffscreenTargetVisible: null when the owner reports not found', async () => { + const rect = await probeWith(async () => ({ found: false }), { label: 'Pickup' }); + + assert.equal(rect, null); +}); - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: { label: 'Checkout form' }, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), +test('confirmIosOffscreenTargetVisible: null on an ambiguous match / any owner error (fail closed)', async () => { + const rect = await probeWith( + async () => { + throw new AppError('AMBIGUOUS_MATCH', 'selector matched multiple elements'); + }, + { label: 'Checkout form' }, ); assert.equal(rect, null); }); test('confirmIosOffscreenTargetVisible: null when the live read is not hittable, even with a plausible rect', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - nodes: [{ index: 0, rect: { x: 126, y: 136, width: 75, height: 38 }, hittable: false }], + const rect = await probeWith(found({ x: 126, y: 136, width: 75, height: 38 }, false), { + identifier: 'shipping-pickup', }); - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: { identifier: 'shipping-pickup' }, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), - ); - assert.equal(rect, null); }); test('confirmIosOffscreenTargetVisible: null when the live rect is hittable but outside the root viewport', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - nodes: [{ index: 0, rect: { x: 5000, y: 5000, width: 75, height: 38 }, hittable: true }], + const rect = await probeWith(found({ x: 5000, y: 5000, width: 75, height: 38 }, true), { + identifier: 'shipping-pickup', }); - const rect = await withRunner(() => - confirmIosOffscreenTargetVisible({ - session: makeSession(), - node: { identifier: 'shipping-pickup' }, - rootViewport: ROOT_VIEWPORT, - requestOptions: {}, - }), - ); + assert.equal(rect, null); +}); + +test('confirmIosOffscreenTargetVisible: null when the owner answers found without a usable node', async () => { + const rect = await probeWith(async () => ({ found: true }) as QuerySelectorResult, { + identifier: 'shipping-pickup', + }); assert.equal(rect, null); }); diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index 329830273a..5b54a055db 100644 --- a/src/daemon/__tests__/selector-runtime.test.ts +++ b/src/daemon/__tests__/selector-runtime.test.ts @@ -1,10 +1,5 @@ import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; -import { beforeEach, expect, test, vi } from 'vitest'; -import assert from 'node:assert/strict'; -import { AppError } from '@agent-device/kernel/errors'; -import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; -import type { SessionState } from '../session-state.ts'; +import { expect, test } from 'vitest'; import { localRuntimeOwner, narrowDeviceBinding, @@ -21,20 +16,6 @@ import { makeSession as makeStoredSession } from '../../__tests__/test-utils/ses import { dispatchGetViaRuntime } from '../selector-runtime.ts'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; -const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ - mockRunAppleRunnerCommand: vi.fn(), -})); - -import { queryDirectIosSelector } from '../direct-ios-selector.ts'; - -beforeEach(() => { - mockRunAppleRunnerCommand.mockReset(); -}); - -function makeSession(): SessionState { - return { name: 'default', device: IOS_SIMULATOR, createdAt: Date.now(), actions: [] }; -} - test.each(['rebuild', 'retire'] as const)( 'get text records in its captured lifetime after a held native read across %s', async (transition) => { @@ -139,98 +120,3 @@ test.each(['rebuild', 'retire'] as const)( } }, ); - -async function withRunner(operation: () => Promise): Promise { - return await withAppleRunnerProvider( - mockRunAppleRunnerCommand, - { deviceId: IOS_SIMULATOR.id }, - operation, - ); -} - -// #1542: queryDirectIosSelector is the ONE querySelector client for the local -// XCTest runner — the offscreen refusal double-check probe -// (src/daemon/offscreen-target-probe.ts) reuses this exact function rather -// than opening a second client, so its request-shape and node-extraction -// contract is covered here, independent of any selector-runtime request. - -test('queryDirectIosSelector: builds the querySelector runner command from key/value/appBundleId', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ found: false, nodes: [] }); - const session = { ...makeSession(), appBundleId: 'com.example.demo' }; - - await withRunner(() => queryDirectIosSelector(session, { key: 'id', value: 'submit-order' }, {})); - - const [device, command] = mockRunAppleRunnerCommand.mock.calls[0] ?? []; - assert.equal(device, session.device); - const { commandId: _commandId, ...commandWithoutId } = command as Record; - assert.deepEqual(commandWithoutId, { - command: 'querySelector', - selectorKey: 'id', - selectorValue: 'submit-order', - appBundleId: 'com.example.demo', - }); -}); - -test('queryDirectIosSelector: accepts a bare {key, value} selector — no `raw` field required', async () => { - // The offscreen double-check derives a selector from a node's own - // identifier/label (deriveDirectIosNodeSelector), which has no `raw` - // string — this must type/run without one. - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - nodes: [{ index: 0, rect: { x: 1, y: 2, width: 3, height: 4 }, hittable: true }], - }); - - const result = await withRunner(() => - queryDirectIosSelector(makeSession(), { key: 'label', value: 'Pickup' }, {}), - ); - - assert.equal(result.found, true); - assert.deepEqual(result.node, { - index: 0, - rect: { x: 1, y: 2, width: 3, height: 4 }, - hittable: true, - }); -}); - -test('queryDirectIosSelector: found:false with no nodes reports not-found without a node', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ found: false, nodes: [] }); - - const result = await withRunner(() => - queryDirectIosSelector(makeSession(), { key: 'id', value: 'missing' }, {}), - ); - - assert.equal(result.found, false); - assert.equal(result.node, undefined); -}); - -test('queryDirectIosSelector: surfaces text when the runner includes it', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - text: 'Ada Lovelace', - nodes: [{ index: 0 }], - }); - - const result = await withRunner(() => - queryDirectIosSelector(makeSession(), { key: 'id', value: 'field-name' }, {}), - ); - - assert.equal(result.text, 'Ada Lovelace'); -}); - -test('queryDirectIosSelector: propagates a runner AMBIGUOUS_MATCH rather than swallowing it', async () => { - mockRunAppleRunnerCommand.mockRejectedValue( - new AppError('AMBIGUOUS_MATCH', 'selector matched multiple elements'), - ); - - await assert.rejects( - () => - withRunner(() => - queryDirectIosSelector(makeSession(), { key: 'label', value: 'Checkout form' }, {}), - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'AMBIGUOUS_MATCH'); - return true; - }, - ); -}); diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts deleted file mode 100644 index 69dfc68acd..0000000000 --- a/src/daemon/apple-runner-options.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; -import type { DaemonRequest } from './daemon-request.ts'; - -export type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; - -export function buildAppleRunnerRequestOptions(params: { - req: Pick; - logPath?: string; - traceLogPath?: string; -}): AppleRunnerRequestOptions { - const { req, logPath, traceLogPath } = params; - return { - verbose: req.flags?.verbose, - logPath, - traceLogPath, - requestId: req.meta?.requestId, - iosXctestrunFile: req.flags?.iosXctestrunFile, - iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, - iosXctestEnvDir: req.flags?.iosXctestEnvDir, - }; -} diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index af9983cf17..e45e4b438b 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -6,8 +6,6 @@ import type { SessionState } from './session-state.ts'; import { readSimpleSelectorTarget } from '@agent-device/selectors'; import { asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { ElementSelectorTapOptions } from '@agent-device/contracts/interactor-types'; -import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts'; -import type { AppleRunnerRequestOptions } from './apple-runner-options.ts'; export type DirectIosSelectorTarget = ElementSelectorTapOptions & { raw: string }; @@ -45,40 +43,6 @@ export function deriveDirectIosNodeSelector( return null; } -export type DirectIosSelectorQueryResult = { - found: boolean; - text?: string; - node?: SnapshotNode; -}; - -export async function queryDirectIosSelector( - session: SessionState, - selector: Pick, - requestOptions: AppleRunnerRequestOptions, -): Promise { - const data = await queryAppleRuntimeSelector( - session.device, - selector, - session.appBundleId, - requestOptions, - ); - const found = data.found === true; - const node = readDirectIosSelectorNode(data); - return { - found, - ...(typeof data.text === 'string' ? { text: data.text } : {}), - ...(node ? { node } : {}), - }; -} - -function readDirectIosSelectorNode(data: Record): SnapshotNode | undefined { - const nodes = data.nodes; - if (!Array.isArray(nodes)) return undefined; - const node = nodes[0]; - if (!node || typeof node !== 'object') return undefined; - return node as SnapshotNode; -} - /** The runner's selector refusals: it resolved the selector and refused before any gesture. */ const RUNNER_SELECTOR_REFUSAL_CODES: ReadonlySet = new Set([ 'ELEMENT_NOT_FOUND', diff --git a/src/daemon/gesture-runtime.ts b/src/daemon/gesture-runtime.ts index 2727fc9ff0..b0d07bb186 100644 --- a/src/daemon/gesture-runtime.ts +++ b/src/daemon/gesture-runtime.ts @@ -23,6 +23,7 @@ import { type RuntimeAdmissionBindings, } from './runtime-admission.ts'; import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; +import type { QuerySelectorRuntimeOperations } from '@agent-device/contracts/selector-observation-runtime'; import type { DaemonFailureResponse } from '@agent-device/kernel/contracts'; /** @@ -43,6 +44,12 @@ export type BoundGestureExecutor = Readonly<{ * without one (Linux). */ gestureViewport?: (context: DaemonCommandContext) => Promise; + /** + * The owner's CONDITIONAL live selector read (#1542 off-screen rescue), present only when this + * gesture tier's use admitted it; the gesture rows' endpoints resolve through the same + * off-screen guard the touch rows do. Absence refuses on the capture's verdict unchanged. + */ + querySelector?: QuerySelectorRuntimeOperations['querySelector']; }>; export type ResolvedGestureRuntime = @@ -138,6 +145,9 @@ async function bindGestureTier( performPlan: async (gesturePlan, context) => await runtime.operations.performGesturePlan(gesturePlanInput(gesturePlan, context)), ...selectGestureFrame(runtime), + ...(runtime.operations.querySelector + ? { querySelector: runtime.operations.querySelector } + : {}), }; } case 'directional-fling': { @@ -148,6 +158,9 @@ async function bindGestureTier( gesturePlanInput(gesturePlan, context), ), ...selectGestureFrame(runtime), + ...(runtime.operations.querySelector + ? { querySelector: runtime.operations.querySelector } + : {}), }; } case 'multi-touch': { @@ -158,6 +171,9 @@ async function bindGestureTier( gesturePlanInput(gesturePlan, context), ), ...selectGestureFrame(runtime), + ...(runtime.operations.querySelector + ? { querySelector: runtime.operations.querySelector } + : {}), }; } case 'target-authored-drag': { @@ -168,6 +184,9 @@ async function bindGestureTier( gesturePlanInput(gesturePlan, context), ), ...selectGestureFrame(runtime), + ...(runtime.operations.querySelector + ? { querySelector: runtime.operations.querySelector } + : {}), }; } } diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 09f66ea6d9..fb6f2ba136 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -443,6 +443,7 @@ function sourceRuntimeFacts( }), ...screenshotRuntimeOperationFacts({ capture: unavailable }), findText: unavailable, + querySelector: unavailable, setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts index 99261e45a3..0d59de9765 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts @@ -4,7 +4,9 @@ import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../../index.ts'; import { + elementReadFixtureState, getRuntimeBindings, + mockQuerySelector, mockTapPoint, resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; @@ -215,6 +217,99 @@ test('press @ref fails closed when stored ref bounds are invalid (ADR 0014)', as expect(mockTapPoint).not.toHaveBeenCalled(); }); +// #1542 wiring, daemon route: the off-screen rescue's live read rides the SAME request-bound +// binding the touch command was admitted for. These two tests pin both declared sides of the +// CONDITIONAL `querySelector` fact: an owner that advertises it answers the guard with a live +// rect the tap lands on; an owner that does not refuses on the bulk-tree verdict with ZERO +// queries. Deleting `conditional: ['querySelector']` from the captured touch uses, or making the +// rescue hook return undefined, breaks one of these two; the Apple binding's own spread is pinned +// in platform-apple's runtime.test.ts. + +const RESCUE_ROUTE_SESSION = 'rescue-route'; + +function offscreenRefSessionStore() { + const sessionStore = makeSessionStore(); + const session = makeSession(RESCUE_ROUTE_SESSION); + session.snapshot = { + nodes: attachRefs([ + { + index: 0, + depth: 0, + type: 'Window', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'XCUIElementTypeButton', + label: 'Far item', + identifier: 'far_item', + rect: { x: 20, y: 1200, width: 120, height: 44 }, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'xctest', + }; + sessionStore.publish(RESCUE_ROUTE_SESSION, session); + return sessionStore; +} + +async function pressOffscreenRef(sessionStore: ReturnType) { + return await handleInteractionCommands({ + createCommandSurface: testCreateCommandSurface, + req: { + token: 't', + session: RESCUE_ROUTE_SESSION, + command: 'press', + positionals: ['@e2'], + flags: {}, + }, + sessionName: RESCUE_ROUTE_SESSION, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); +} + +test('press @ref an off-screen node is rescued to the live rect when the owner advertises querySelector', async () => { + const sessionStore = offscreenRefSessionStore(); + elementReadFixtureState.querySelectorAvailable = true; + mockQuerySelector.mockResolvedValue({ + found: true, + node: { rect: { x: 20, y: 400, width: 120, height: 44 }, hittable: true }, + }); + + const response = await pressOffscreenRef(sessionStore); + + // The rescue wins: the press lands at the CENTER OF THE LIVE RECT, not the stale + // bulk-tree row's off-screen geometry, and the live read carried the derived selector. + expect(response?.ok).toBe(true); + expect(mockQuerySelector).toHaveBeenCalledTimes(1); + expect(mockQuerySelector.mock.calls[0]?.[0].selector).toEqual({ + key: 'id', + value: 'far_item', + }); + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 80, y: 422 }); +}); + +test('press @ref an off-screen node refuses with zero queries when the owner does not advertise querySelector', async () => { + // The fixture default: the owner's facts declare the CONDITIONAL read unavailable, so the + // route holds no hook and the guard's refusal is built from the bulk tree alone. + const sessionStore = offscreenRefSessionStore(); + + const response = await pressOffscreenRef(sessionStore); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.details?.reason).toBe('offscreen_ref'); + } + expect(mockQuerySelector).not.toHaveBeenCalled(); + expect(mockTapPoint).not.toHaveBeenCalled(); +}); + test('press @ref fails fast when the target is off-screen', async () => { const sessionStore = makeSessionStore(); const sessionName = 'press-offscreen-ref'; diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index 1f578f334f..c50fb21d41 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -10,10 +10,9 @@ import { getRequestSignal } from '@agent-device/host-kit/request'; import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; import type { DaemonCommandContext } from '../../context.ts'; import { createDaemonRuntimePolicy } from '../../runtime-policy.ts'; -import { buildAppleRunnerRequestOptions } from '../../apple-runner-options.ts'; -import { isLocalIosRunnerSession } from '../../direct-ios-selector.ts'; import { recordCaptureProof } from '../../capture-disclosure.ts'; import { confirmIosOffscreenTargetVisible } from '../../offscreen-target-probe.ts'; +import { runtimeExecutionFromContext } from '../../snapshot-runtime-capture-input.ts'; import { createDaemonRuntimeSessionStore } from '../../runtime-session.ts'; import { expireRefFrame } from '../../ref-frame.ts'; import { setSessionSnapshot } from '../../session-snapshot.ts'; @@ -83,27 +82,47 @@ export async function createInteractionRuntimeForRoute( }, }), expireRefFrame: () => expireRefFrame(params.sessionStore.requireCurrent(ref)), - confirmOffscreenTargetVisible: isLocalIosRunnerSession(session, { - skipPendingPostGestureStabilization: false, - }) - ? async (node, rootViewport) => - await confirmIosOffscreenTargetVisible({ - session, - node, - rootViewport, - requestOptions: buildAppleRunnerRequestOptions({ - req: params.req, - logPath: params.logPath, - traceLogPath: session.trace?.outPath, - }), - }) - : undefined, + // The #1542 rescue hook installs only when this request's own binding admitted the live + // selector read — the same conditional fact the guard's refusal is built on, so the hook and + // the verdict it may overturn come from one admission (ADR 0019 §2). A session whose owner did + // not advertise the read keeps the bulk-tree verdict unchanged. + confirmOffscreenTargetVisible: readOffscreenTargetRescue(params, session), pairedGestureViewport: params.pairedGestureViewport, touchExecutor: params.touchExecutor, gestures: params.gestures, }); } +/** + * The #1542 rescue read over the live selector query, or `undefined` when this request must not + * hold one. It installs only on a route whose own request-bound binding admitted the CONDITIONAL + * `querySelector` operation (the touch or gesture use it already holds; ADR 0019 §2), which is the + * whole gate: only the local Apple owner declares the read, and only on iOS-family leaves, so + * provider-owned devices (whose facts start every operation unavailable and never declare it), + * the macOS desktop, and every other platform never hold the hook — their guard refuses on the + * bulk-tree verdict unchanged. + */ +function readOffscreenTargetRescue( + params: InteractionRouteInput & { + touchExecutor?: BoundTouchExecutor; + gestures?: BoundGestureExecutor; + }, + session: Parameters[0]['session'], +): InteractionRuntimeInput['confirmOffscreenTargetVisible'] { + const querySelector = params.touchExecutor?.querySelector ?? params.gestures?.querySelector; + if (!querySelector) return undefined; + return async (node, rootViewport) => + await confirmIosOffscreenTargetVisible({ + session, + node, + rootViewport, + querySelector, + execution: runtimeExecutionFromContext( + params.contextFromFlags(params.req.flags, session.appBundleId, session.trace?.outPath), + ), + }); +} + type FinalizeTouchInteractionInput = Omit< Parameters[0], 'operations' diff --git a/src/daemon/offscreen-target-probe.ts b/src/daemon/offscreen-target-probe.ts index 106bbd0670..ff253d7e98 100644 --- a/src/daemon/offscreen-target-probe.ts +++ b/src/daemon/offscreen-target-probe.ts @@ -1,25 +1,42 @@ import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; import { isConfirmedOnScreenProbe } from '@agent-device/capture-kit/mobile-snapshot-semantics'; -import { deriveDirectIosNodeSelector, queryDirectIosSelector } from './direct-ios-selector.ts'; -import type { AppleRunnerRequestOptions } from './apple-runner-options.ts'; +import type { QuerySelectorRuntimeOperations } from '@agent-device/contracts/selector-observation-runtime'; +import type { SnapshotRuntimeExecution } from '@agent-device/contracts/snapshot-runtime'; +import { deriveDirectIosNodeSelector } from './direct-ios-selector.ts'; import type { SessionState } from './session-state.ts'; +/** + * The #1542 rescue read: one live `querySelector` on the caller's request-bound operation, + * projected against the root viewport. The session's app scope rides along — the runner read is + * scoped to an application exactly as the retired daemon-side probe scoped its own call. The + * read fails closed (null) on anything short of a positive, hittable, in-viewport answer, and + * an owner error stays swallowed here: a failed call must look like a genuine refusal, never a + * crash inside a guard that was about to refuse anyway. + */ export async function confirmIosOffscreenTargetVisible(params: { session: SessionState; node: Pick; rootViewport: Rect | null; - requestOptions: AppleRunnerRequestOptions; + querySelector: QuerySelectorRuntimeOperations['querySelector']; + execution: SnapshotRuntimeExecution; }): Promise { - const { session, node, rootViewport, requestOptions } = params; + const { session, node, rootViewport, querySelector, execution } = params; const selector = deriveDirectIosNodeSelector(node); if (!selector) return null; - let result: Awaited>; + let found: boolean; + let nodeReading: Awaited>['node']; try { - result = await queryDirectIosSelector(session, selector, requestOptions); + ({ found, node: nodeReading } = await querySelector({ + selector, + ...(session.appBundleId === undefined + ? {} + : { options: { appBundleId: session.appBundleId } }), + execution, + })); } catch { return null; } - if (!result.found || !result.node?.rect) return null; - const probe = { rect: result.node.rect, hittable: result.node.hittable === true }; + if (!found || !nodeReading?.rect) return null; + const probe = { rect: nodeReading.rect, hittable: nodeReading.hittable === true }; return isConfirmedOnScreenProbe(probe, rootViewport) ? probe.rect : null; } diff --git a/src/daemon/runtime-operation-effects.ts b/src/daemon/runtime-operation-effects.ts index 9babbf842c..cf2e1b6c89 100644 --- a/src/daemon/runtime-operation-effects.ts +++ b/src/daemon/runtime-operation-effects.ts @@ -72,6 +72,7 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly< performTargetAuthoredDrag: 'mutates', prepareAppleRunner: 'repeatable', prepareApplicationOpen: 'repeatable', + querySelector: 'repeatable', readAlert: 'repeatable', readClipboard: 'repeatable', readSetting: 'repeatable', diff --git a/src/daemon/touch-runtime.ts b/src/daemon/touch-runtime.ts index addeb01273..4dbb9b8f90 100644 --- a/src/daemon/touch-runtime.ts +++ b/src/daemon/touch-runtime.ts @@ -24,6 +24,7 @@ import { successText } from '@agent-device/kernel/success-text'; import { requireIntInRange } from '@agent-device/kernel/validation'; import type { DaemonCommandContext } from './context.ts'; import type { DirectIosSelectorTarget } from './direct-ios-selector.ts'; +import type { QuerySelectorRuntimeOperations } from '@agent-device/contracts/selector-observation-runtime'; import { admitRuntimeOperations, type RuntimeAdmissionBindings, @@ -188,6 +189,12 @@ export type BoundTouchExecutor = Readonly<{ options?: { delayMs?: number }, ) => Promise | void>; tapElementSelector?: (selector: DirectIosSelectorTarget) => Promise>; + /** + * The owner's CONDITIONAL live selector read (#1542 off-screen rescue). Present only when this + * captured touch bind admitted it; its absence is the parity side of the declaration — the + * guard refuses on the capture's verdict unchanged. + */ + querySelector?: QuerySelectorRuntimeOperations['querySelector']; }>; export function createBoundTouchExecutor( @@ -199,6 +206,20 @@ export function createBoundTouchExecutor( appBundleId: context.appBundleId, }; const captureSnapshot = bound.captured ? readCaptureSnapshot(bound.runtime) : undefined; + const base = createBaseTouchExecutor(bound, context, shared, captureSnapshot); + // The owner's live selector read (#1542 rescue), forwarded only on the captured arms whose use + // declares it CONDITIONAL and only when the exact owner advertised it. The freeze keeps the + // executor's shape identical to the builders' own. + const querySelector = bound.captured ? bound.runtime.operations.querySelector : undefined; + return querySelector ? Object.freeze({ ...base, querySelector }) : base; +} + +function createBaseTouchExecutor( + bound: BoundTouchRuntime, + context: DaemonCommandContext, + shared: TouchSharedInput, + captureSnapshot: BoundTouchExecutor['captureSnapshot'], +): BoundTouchExecutor { switch (bound.kind) { case 'tap': return createTapTouchExecutor(bound, context, shared, captureSnapshot); diff --git a/src/platform-runtime-apple-resources.ts b/src/platform-runtime-apple-resources.ts index a00426696f..fd7df267d6 100644 --- a/src/platform-runtime-apple-resources.ts +++ b/src/platform-runtime-apple-resources.ts @@ -1,7 +1,4 @@ -import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; import type { AppleSessionObservation } from '@agent-device/contracts/apple-session-observation'; -import type { ElementSelectorKey } from '@agent-device/contracts/interactor-types'; -import type { DeviceInfo } from '@agent-device/kernel/device'; let observation: AppleSessionObservation | undefined; @@ -26,14 +23,3 @@ export const appleSessionObservation: AppleSessionObservation = Object.freeze({ return (await loadAppleSessionObservation()).resolveSoleForegroundApp(options); }, }); - -export async function queryAppleRuntimeSelector( - device: DeviceInfo, - selector: Readonly<{ key: ElementSelectorKey; value: string }>, - appBundleId: string | undefined, - options: AppleRunnerRequestOptions, -): Promise> { - const { queryAppleRunnerSelector } = - await import('@agent-device/platform-apple/runner/operations'); - return await queryAppleRunnerSelector(device, selector, appBundleId, options); -}