From 58d8b1f3584e4f13ede212c95a9b399e46b9ee5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Tue, 15 Sep 2026 17:27:55 +0200 Subject: [PATCH 1/2] feat(ios): type into the Apple Pay sheet by serving PassbookUIService in place Bare `type` on iOS types into the first responder of the process the runner addresses, gated on that process showing a keyboard. The Apple Pay sheet and its shipping/contact forms live in com.apple.PassbookUIService, so the runner saw no keyboard in the session app and refused with TEXT_INPUT_NOT_FOCUSED. Register com.apple.PassbookUIService as a second system surface host (kind `payment`) next to the web-auth host, in the golden fixture and both the TS and Swift registries. The existing serve-in-place machinery then routes snapshots to the runner, addresses the host for interactions, and the synthesized first-responder route types into the sheet. Make the disclosure wording kind-aware: `iosSystemSurfaceDisclosure(bundleId)` replaces the single web sign-in constant, the transition disclosure takes the `{ from, to }` surface pair, and the wire reader takes the surface kind from the registry instead of trusting the runner payload. Test app: add an Apple Pay lab native module and an `accessible={true}` flattened TextInput fixture to the Automation lab; pin expo-modules-jsi to 56.0.13 so the app builds under Xcode 27. --- .../RunnerSystemSurfaceHostPolicy.swift | 16 ++- .../fixtures/ios-system-surface-hosts.json | 10 +- .../adr/0004-ios-snapshot-backend-strategy.md | 12 +- examples/test-app/README.md | 10 +- examples/test-app/app.config.js | 5 + .../apple-pay-lab/expo-module.config.json | 6 + .../apple-pay-lab/ios/ApplePayLab.podspec | 16 +++ .../apple-pay-lab/ios/ApplePayLabModule.swift | 92 ++++++++++++++++ examples/test-app/pnpm-lock.yaml | 9 +- examples/test-app/pnpm-workspace.yaml | 3 + .../src/screens/AutomationLabScreen.tsx | 83 ++++++++++++++ packages/contracts/src/interaction.ts | 6 +- .../contracts/src/ios-system-surface.test.ts | 52 ++++++--- packages/contracts/src/ios-system-surface.ts | 104 +++++++++++------- .../platform-apple/src/core/app-launch.ts | 4 +- packages/platform-apple/src/interactor.ts | 7 +- .../__tests__/snapshot-presentation.test.ts | 17 +++ .../src/runner/snapshot-presentation.ts | 17 ++- .../src/system-surface-presence.test.ts | 4 +- .../interaction/runtime/interactions.test.ts | 7 +- .../runtime/post-action-surface.test.ts | 9 +- .../runtime/post-action-surface.ts | 17 +-- src/daemon/__tests__/generic-settle.test.ts | 18 ++- .../system-surface-disclosure.test.ts | 20 ++-- src/daemon/system-surface-disclosure.ts | 8 +- src/mcp/command-output-schemas.ts | 2 +- 26 files changed, 434 insertions(+), 120 deletions(-) create mode 100644 examples/test-app/modules/apple-pay-lab/expo-module.config.json create mode 100644 examples/test-app/modules/apple-pay-lab/ios/ApplePayLab.podspec create mode 100644 examples/test-app/modules/apple-pay-lab/ios/ApplePayLabModule.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemSurfaceHostPolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemSurfaceHostPolicy.swift index ed31d59b31..97dd753e28 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemSurfaceHostPolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemSurfaceHostPolicy.swift @@ -1,11 +1,13 @@ import Foundation // iOS out-of-process system surfaces observed and driven IN PLACE, never activated: activating such -// a host cancels what it presents (issue #2438; rationale in docs/adr/0004). Membership is the -// golden fixture contracts/fixtures/ios-system-surface-hosts.json, mirrored by the TS twin -// packages/contracts/src/ios-system-surface.ts; drift fails on either side without a simulator. +// a host cancels what it presents (issue #2438; rationale in docs/adr/0004). Membership and each +// host's rationale live in the golden fixture contracts/fixtures/ios-system-surface-hosts.json, +// mirrored by the TS twin packages/contracts/src/ios-system-surface.ts; drift fails on either side +// without a simulator. enum SystemSurfaceHostKind: String { case webAuth = "web-auth" + case payment = "payment" } struct SystemSurfaceHost: Equatable { @@ -15,7 +17,8 @@ struct SystemSurfaceHost: Equatable { enum SystemSurfaceHostRegistry { static let hosts: [SystemSurfaceHost] = [ - SystemSurfaceHost(bundleId: "com.apple.SafariViewService", kind: .webAuth) + SystemSurfaceHost(bundleId: "com.apple.SafariViewService", kind: .webAuth), + SystemSurfaceHost(bundleId: "com.apple.PassbookUIService", kind: .payment), ] static func host(forBundleId bundleId: String?) -> SystemSurfaceHost? { @@ -61,12 +64,17 @@ extension RunnerTests { func testSystemSurfaceHostRegistryRecognizesRegisteredHosts() { XCTAssertTrue(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.apple.SafariViewService")) + XCTAssertTrue(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.apple.PassbookUIService")) XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.example.app")) XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost(nil)) XCTAssertEqual( SystemSurfaceHostRegistry.host(forBundleId: "com.apple.SafariViewService")?.kind, .webAuth ) + XCTAssertEqual( + SystemSurfaceHostRegistry.host(forBundleId: "com.apple.PassbookUIService")?.kind, + .payment + ) } } #endif diff --git a/contracts/fixtures/ios-system-surface-hosts.json b/contracts/fixtures/ios-system-surface-hosts.json index 2b34b5f170..0224ed74e3 100644 --- a/contracts/fixtures/ios-system-surface-hosts.json +++ b/contracts/fixtures/ios-system-surface-hosts.json @@ -1,11 +1,17 @@ { - "description": "iOS out-of-process system surfaces that agent-device observes and drives IN PLACE, never by activation. Activating or relaunching such a host destroys what it presents: com.apple.SafariViewService hosts ASWebAuthenticationSession / SFSafariViewController, and any XCUIApplication.activate() or simctl launch cancels the auth session (issue #2438). Source of truth shared by the TypeScript registry (packages/contracts/src/ios-system-surface.ts) and the Swift runner registry (RunnerSystemSurfaceHostPolicy.swift); a change here must keep both parity tests green. `processExecutable` is the simulator app-binary path fragment the TypeScript host-side presence probe matches with `pgrep -f`, confirming device scope from the matched process's environment; the Swift runner detects the host by bundle id via XCUIApplication.state and ignores it.", + "description": "iOS out-of-process system surfaces that agent-device observes and drives IN PLACE, never by activation: activating or relaunching such a host destroys what it presents (issue #2438; rationale in docs/adr/0004). Each host's `note` says what it hosts and why it is registered. Source of truth shared by the TypeScript registry (packages/contracts/src/ios-system-surface.ts) and the Swift runner registry (RunnerSystemSurfaceHostPolicy.swift); a change here must keep both parity tests green. `processExecutable` is the simulator app-binary path fragment the TypeScript host-side presence probe matches with `pgrep -f`, confirming device scope from the matched process's environment; the Swift runner detects the host by bundle id via XCUIApplication.state and ignores it.", "hosts": [ { "bundleId": "com.apple.SafariViewService", "kind": "web-auth", "processExecutable": "SafariViewService.app/SafariViewService", - "note": "Hosts ASWebAuthenticationSession and SFSafariViewController out of the app's process. Presented over a still-foreground app; read and driven in place via the XCTest runner (the host AX bridge cannot see it: the app remains the AX primaryApp)." + "note": "Hosts ASWebAuthenticationSession and SFSafariViewController out of the app's process; any XCUIApplication.activate() or simctl launch cancels the auth session. Presented over a still-foreground app; read and driven in place via the XCTest runner (the host AX bridge cannot see it: the app remains the AX primaryApp)." + }, + { + "bundleId": "com.apple.PassbookUIService", + "kind": "payment", + "processExecutable": "PassbookUIService.app/PassbookUIService", + "note": "Hosts the Apple Pay sheet (PKPaymentAuthorizationController) and its billing/shipping/contact forms out of the app's process. Those text fields never appear in the app's tree, so bare `type` must address this host to reach the first responder." } ] } diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index b20b6d0faf..998edd4ba0 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -358,10 +358,16 @@ predicate sound. This also makes issue #2438's second bug (a stale tree served c teardown) unrepresentable for the delegated-auth flow, because the session never binds to the host. Captures of a system surface carry a response-level `systemSurface` provenance and the shared -`IOS_SYSTEM_SURFACE_DISCLOSURE`, so the agent is told the controls belong to a system sheet rather -than the app. They are also lineaged to the host rather than the app, so their comparison identity -differs from an app capture's by construction: every consumer that asks "are these two captures the +`iosSystemSurfaceDisclosure`, worded per host kind, so the agent is told the controls belong to a +system sheet (web sign-in, Apple Pay) rather than the app. They are also lineaged to the host rather +than the app, so their comparison identity differs from an app capture's by construction: every consumer that asks "are these two captures the same presentation" refuses a cross-surface pair through ordinary key equality, and no comparison site carries a surface check of its own. Physical devices always use the runner, so the in-place serve applies there without a route change; the Simulator route probe is the only Simulator-specific piece. + +The Apple Pay host (`com.apple.PassbookUIService`) joined the registry for text entry as much as +for snapshots. Its billing, shipping, and contact forms hold text fields the session app's tree cannot +resolve, and a bare `type` addressed to the app process never sees that keyboard. Addressing the +host in place is what lets the runner's first-responder route type into them; no text-entry branch +changed for it. diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 0439cab8f6..1cdc3f2aad 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -16,7 +16,7 @@ It is intentionally small, but each surface is dense with durable accessibility - `Product detail`: back navigation, quantity stepper, multiline notes, save action - `Checkout form`: required-field validation, fill vs type, checkbox state, choice groups, keyboard dismiss, success summary - `Settings`: switch rows, accordion content, loading and error states, retry flow, destructive-confirm modal -- `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, and log canaries +- `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService` - `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface. @@ -38,6 +38,7 @@ These are the main case families this app can support without adding more screen - `press` on stable buttons, pills, and rows - `fill` on single-line and multiline fields - `type` after focus for append flows +- `type` into a focused field the accessibility tree cannot resolve (flattened input, Apple Pay billing address form) - `get text` on headings, badges, summaries, and accordion content - `is visible`, `is exists`, and `is absent` assertions - `wait` for async loading and success states @@ -90,7 +91,12 @@ The `/automation` route is intentionally JavaScript-only and can be opened from outcomes for long press, native alert actions, app-event name/payload, app state, appearance, window orientation, and microphone permission recovery; the `maestro-clickable-first-target` duplicate pair exercises Android Maestro clickable-first -ordering. CI repacks JavaScript-only changes into the +ordering. `automation-flattened-group` wraps a `TextInput` in an `accessible={true}` view, so the +field itself never appears in the accessibility tree and only the keyboard proves it has focus; +`automation-flattened-value` mirrors what was typed. `automation-open-apple-pay` (iOS only, native +module `modules/apple-pay-lab`) presents the system Apple Pay sheet requiring a billing address plus +contact email and phone; those forms are hosted out of process in `com.apple.PassbookUIService`, and +`automation-apple-pay-result` reports `authorized` or `dismissed` once the sheet closes. CI repacks JavaScript-only changes into the cached Release app without starting Metro; native configuration changes intentionally produce one new fingerprinted build that all simulator consumers share. diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js index 123ce3b969..0da014b204 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -34,6 +34,11 @@ module.exports = { supportsTablet: true, bundleIdentifier: 'com.callstack.agentdevicelab', infoPlist: accessoryInfoPlist, + // Lets the simulator present the Apple Pay sheet (modules/apple-pay-lab). The merchant id + // is a fixture, not a registered merchant. + entitlements: { + 'com.apple.developer.in-app-payments': ['merchant.com.callstack.agentdevicelab'], + }, }, android: { package: 'com.callstack.agentdevicelab', diff --git a/examples/test-app/modules/apple-pay-lab/expo-module.config.json b/examples/test-app/modules/apple-pay-lab/expo-module.config.json new file mode 100644 index 0000000000..96254faf3a --- /dev/null +++ b/examples/test-app/modules/apple-pay-lab/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "apple": { + "modules": ["ApplePayLabModule"] + } +} diff --git a/examples/test-app/modules/apple-pay-lab/ios/ApplePayLab.podspec b/examples/test-app/modules/apple-pay-lab/ios/ApplePayLab.podspec new file mode 100644 index 0000000000..e11f21a34a --- /dev/null +++ b/examples/test-app/modules/apple-pay-lab/ios/ApplePayLab.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'ApplePayLab' + s.version = '1.0.0' + s.summary = 'Apple Pay sheet fixture for Agent Device Tester' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = { 'Callstack' => 'opensource@callstack.com' } + s.homepage = 'https://github.com/callstack/agent-device' + s.platforms = { :ios => '15.1' } + s.source = { :git => 'https://github.com/callstack/agent-device.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'PassKit', 'UIKit' + s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' +end diff --git a/examples/test-app/modules/apple-pay-lab/ios/ApplePayLabModule.swift b/examples/test-app/modules/apple-pay-lab/ios/ApplePayLabModule.swift new file mode 100644 index 0000000000..020431b730 --- /dev/null +++ b/examples/test-app/modules/apple-pay-lab/ios/ApplePayLabModule.swift @@ -0,0 +1,92 @@ +import ExpoModulesCore +import PassKit + +// Presents the system Apple Pay sheet, which iOS hosts out of process in +// com.apple.PassbookUIService. The billing address and contact forms inside that sheet are the +// fixture for typing into a focused field that the app's own accessibility tree cannot resolve. +public final class ApplePayLabModule: Module { + public func definition() -> ModuleDefinition { + Name("ApplePayLab") + + Function("canMakePayments") { () -> Bool in + PKPaymentAuthorizationController.canMakePayments() + } + + AsyncFunction("presentPaymentSheetAsync") { (promise: Promise) in + ApplePayLabController.shared.present(promise: promise) + }.runOnQueue(.main) + } +} + +private final class ApplePayLabController: NSObject, PKPaymentAuthorizationControllerDelegate { + static let shared = ApplePayLabController() + + // Must match com.apple.developer.in-app-payments in app.config.js. The simulator accepts any + // merchant id declared there and offers its built-in test cards. + private static let merchantIdentifier = "merchant.com.callstack.agentdevicelab" + + // One presented sheet at a time. Holding the controller keeps it alive until PassKit reports + // that it finished; the promise resolves with the outcome then. + private struct Session { + let controller: PKPaymentAuthorizationController + let promise: Promise + var authorized = false + } + + private var session: Session? + + func present(promise: Promise) { + guard session == nil else { + promise.reject( + Exception( + name: "PaymentSheetAlreadyPresented", + description: "The Apple Pay sheet is already presented." + ) + ) + return + } + + let request = PKPaymentRequest() + request.merchantIdentifier = Self.merchantIdentifier + request.countryCode = "US" + request.currencyCode = "USD" + request.supportedNetworks = [.visa, .masterCard, .amex] + request.merchantCapabilities = .threeDSecure + request.requiredBillingContactFields = [.postalAddress] + request.requiredShippingContactFields = [.emailAddress, .phoneNumber] + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "Agent Device Tester", amount: NSDecimalNumber(string: "1.00")), + ] + + let controller = PKPaymentAuthorizationController(paymentRequest: request) + controller.delegate = self + session = Session(controller: controller, promise: promise) + controller.present { presented in + guard !presented else { return } + self.session = nil + promise.reject( + Exception( + name: "PaymentSheetNotPresented", + description: "iOS refused to present the Apple Pay sheet." + ) + ) + } + } + + func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didAuthorizePayment payment: PKPayment, + handler completion: @escaping (PKPaymentAuthorizationResult) -> Void + ) { + session?.authorized = true + completion(PKPaymentAuthorizationResult(status: .success, errors: nil)) + } + + func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { + controller.dismiss { + guard let session = self.session else { return } + self.session = nil + session.promise.resolve(session.authorized ? "authorized" : "dismissed") + } + } +} diff --git a/examples/test-app/pnpm-lock.yaml b/examples/test-app/pnpm-lock.yaml index 9cb0f6688a..ccea9e00b7 100644 --- a/examples/test-app/pnpm-lock.yaml +++ b/examples/test-app/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: js-yaml@4: ^4.3.1 nanoid@3: ^3.3.18 '@babel/core@7': ^7.29.6 + expo-modules-jsi: 56.0.13 patchedDependencies: image-size@1.2.1: 6544266162325551c6f85c53ca52e5b3b25ccf96127a57a4ebcc3553e89584bb @@ -1661,8 +1662,8 @@ packages: react-native-worklets: optional: true - expo-modules-jsi@56.0.10: - resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==} + expo-modules-jsi@56.0.13: + resolution: {integrity: sha512-If9W5Me4aSaBSgLD6MPUm6YDVB0KNsK2gIEaTeLEO9dnTqmRcJ3WM206S8iix+4GSvXMOyE8mLlmqE1cQMUuyQ==} peerDependencies: react-native: '*' @@ -4902,14 +4903,14 @@ snapshots: expo-modules-core@56.0.17(react-native-worklets@0.10.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.2.2 - expo-modules-jsi: 56.0.10(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) + expo-modules-jsi: 56.0.13(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) invariant: 2.2.4 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) optionalDependencies: react-native-worklets: 0.10.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1) - expo-modules-jsi@56.0.10(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)): + expo-modules-jsi@56.0.13(react-native@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)): dependencies: react-native: 0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) diff --git a/examples/test-app/pnpm-workspace.yaml b/examples/test-app/pnpm-workspace.yaml index 42c924ed03..1a07b85ff4 100644 --- a/examples/test-app/pnpm-workspace.yaml +++ b/examples/test-app/pnpm-workspace.yaml @@ -20,5 +20,8 @@ overrides: js-yaml@4: ^4.3.1 nanoid@3: ^3.3.18 '@babel/core@7': ^7.29.6 + # expo-modules-core pins ~56.0.10; 56.0.13 is the first patch whose Swift compiles under Xcode 27 + # (JavaScriptRuntime.swift formed a C function pointer from a ternary). + expo-modules-jsi: 56.0.13 patchedDependencies: image-size@1.2.1: patches/image-size@1.2.1.patch diff --git a/examples/test-app/src/screens/AutomationLabScreen.tsx b/examples/test-app/src/screens/AutomationLabScreen.tsx index 41ddf69443..fcdc0f8524 100644 --- a/examples/test-app/src/screens/AutomationLabScreen.tsx +++ b/examples/test-app/src/screens/AutomationLabScreen.tsx @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, Text, + TextInput, useColorScheme, useWindowDimensions, View, @@ -34,6 +35,19 @@ const pushBroadcastLab = ? requireOptionalNativeModule('PushBroadcastLab') : null; +type ApplePayLabModule = { + canMakePayments(): boolean; + presentPaymentSheetAsync(): Promise; +}; + +const applePayLab = + Platform.OS === 'ios' ? requireOptionalNativeModule('ApplePayLab') : null; + +function initialApplePayResult(): string { + if (!applePayLab) return 'unavailable'; + return applePayLab.canMakePayments() ? 'ready' : 'unsupported'; +} + export function AutomationLabScreen(props: { eventName: string; eventPayload: string; @@ -53,6 +67,8 @@ export function AutomationLabScreen(props: { const [lastPushBroadcast, setLastPushBroadcast] = useState('none'); const [sheetVisible, setSheetVisible] = useState(false); const [keychainAuthStatus, setKeychainAuthStatus] = useState('checking'); + const [applePayResult, setApplePayResult] = useState(initialApplePayResult); + const [flattenedInput, setFlattenedInput] = useState(''); const permissionReadGeneration = useRef(0); const windowMode = dimensions.width > dimensions.height ? 'landscape' : 'portrait'; @@ -153,6 +169,16 @@ export function AutomationLabScreen(props: { setKeychainAuthStatus('signed-in'); } + async function presentApplePaySheet() { + if (!applePayLab) return; + setApplePayResult('presented'); + try { + setApplePayResult(await applePayLab.presentPaymentSheetAsync()); + } catch { + setApplePayResult('error'); + } + } + return ( + + + Nickname + + + + Flattened value: {flattenedInput === '' ? 'none' : flattenedInput} + + + + {Platform.OS === 'ios' ? ( + + void presentApplePaySheet()} + testID="automation-open-apple-pay" + /> + + + ) : null} + { ); }); -test('isIosSystemSurfaceHost recognizes registered hosts and rejects others', () => { - expect(isIosSystemSurfaceHost('com.apple.SafariViewService')).toBe(true); - expect(isIosSystemSurfaceHost('com.example.app')).toBe(false); - expect(isIosSystemSurfaceHost(undefined)).toBe(false); +test('iosSystemSurfaceHost resolves registered hosts and rejects others', () => { + expect(iosSystemSurfaceHost(WEB_AUTH_HOST)?.kind).toBe('web-auth'); + expect(iosSystemSurfaceHost(PAYMENT_HOST)?.kind).toBe('payment'); + expect(iosSystemSurfaceHost('com.example.app')).toBeUndefined(); + expect(iosSystemSurfaceHost(undefined)).toBeUndefined(); }); -test('the open refusal names the bundle and does not claim to open it', () => { - const refusal = iosSystemSurfaceOpenRefusal('com.apple.SafariViewService'); - expect(refusal).toContain('com.apple.SafariViewService'); +test('the open refusal names the bundle and its sheet and does not claim to open it', () => { + const refusal = iosSystemSurfaceOpenRefusal(WEB_AUTH_HOST); + expect(refusal).toContain(WEB_AUTH_HOST); + expect(refusal).toContain('web sign-in'); expect(refusal.toLowerCase()).not.toContain('opened it'); + expect(iosSystemSurfaceOpenRefusal(PAYMENT_HOST)).toContain('Apple Pay'); +}); + +test('the standing disclosure names the kind of sheet the host presents', () => { + expect(iosSystemSurfaceDisclosure(WEB_AUTH_HOST)).toContain('a system web sign-in sheet'); + expect(iosSystemSurfaceDisclosure(PAYMENT_HOST)).toContain('the system Apple Pay sheet'); + expect(iosSystemSurfaceDisclosure(PAYMENT_HOST)).not.toContain('sign-in'); +}); + +// Only registered hosts are ever stamped on a capture, so an unregistered id reaching a sentence +// is a programming error and must not be described as some plausible sheet. +test('an unregistered host cannot be worded', () => { + expect(() => iosSystemSurfaceDisclosure('com.example.unknown')).toThrow(/not a registered/); }); test('the transition disclosure says the sheet is gone only when it left', () => { - expect(iosSystemSurfaceTransitionDisclosure('com.apple.SafariViewService')).toBe( - IOS_SYSTEM_SURFACE_DISCLOSURE, + expect(iosSystemSurfaceTransitionDisclosure({ from: APP_SURFACE, to: WEB_AUTH_HOST })).toBe( + iosSystemSurfaceDisclosure(WEB_AUTH_HOST), + ); + expect(iosSystemSurfaceTransitionDisclosure({ from: APP_SURFACE, to: PAYMENT_HOST })).toBe( + iosSystemSurfaceDisclosure(PAYMENT_HOST), ); - const departed = iosSystemSurfaceTransitionDisclosure(undefined); - expect(departed).not.toBe(IOS_SYSTEM_SURFACE_DISCLOSURE); + const departed = iosSystemSurfaceTransitionDisclosure({ from: WEB_AUTH_HOST, to: APP_SURFACE }); + expect(departed).not.toBe(iosSystemSurfaceDisclosure(WEB_AUTH_HOST)); expect(departed).toContain('gone now'); + expect(departed).toContain('web sign-in'); + expect(iosSystemSurfaceTransitionDisclosure({ from: PAYMENT_HOST, to: APP_SURFACE })).toContain( + 'Apple Pay', + ); }); diff --git a/packages/contracts/src/ios-system-surface.ts b/packages/contracts/src/ios-system-surface.ts index e9fb17be57..d1555c6e6d 100644 --- a/packages/contracts/src/ios-system-surface.ts +++ b/packages/contracts/src/ios-system-surface.ts @@ -1,20 +1,22 @@ /** * iOS out-of-process system surfaces that agent-device observes and drives IN PLACE, never by - * activation. - * - * `com.apple.SafariViewService` hosts `ASWebAuthenticationSession` and `SFSafariViewController` - * out of the app's process. It is presented over a still-foreground app, and any - * `XCUIApplication.activate()` or `simctl launch` on it cancels the authentication session and - * blacks the view (issue #2438). So the runner reads and drives it without activation, and the - * `open` path refuses to launch it. The set is deliberately closed and tiny; add a host only with - * live evidence that it presents out of process and dies on activation. - * - * The canonical membership lives in `contracts/fixtures/ios-system-surface-hosts.json`; this module - * and the Swift `SystemSurfaceHostRegistry` both mirror it, each guarded by a parity test. + * activation: launching or activating the host cancels what it presents (issue #2438), so the + * runner addresses the host process while it is foreground and the `open` path refuses it. The set + * is deliberately closed and tiny; add a host only with live evidence that it presents out of + * process and dies on activation. Rationale per host lives in the canonical fixture + * `contracts/fixtures/ios-system-surface-hosts.json` (see also docs/adr/0004); this module and the + * Swift `SystemSurfaceHostRegistry` both mirror it, each guarded by a parity test. */ /** Why a system surface is served in place; carried at snapshot-response level as provenance. */ -export type IosSystemSurfaceKind = 'web-auth'; +export type IosSystemSurfaceKind = 'web-auth' | 'payment'; + +/** + * How a capture of ordinary app content names its surface in a `PostActionSurfaceChange` + * (`@agent-device/contracts/interaction`), against a host bundle id for a sheet. Lives here, not + * beside that type, so this module keeps its zero-import closure. + */ +export const APP_SURFACE = 'app'; export type IosSystemSurfaceHost = Readonly<{ bundleId: string; @@ -33,25 +35,22 @@ export const IOS_SYSTEM_SURFACE_HOSTS: readonly IosSystemSurfaceHost[] = Object. kind: 'web-auth' as const, processExecutable: 'SafariViewService.app/SafariViewService', }), + Object.freeze({ + bundleId: 'com.apple.PassbookUIService', + kind: 'payment' as const, + processExecutable: 'PassbookUIService.app/PassbookUIService', + }), ]); const HOST_BY_BUNDLE_ID: ReadonlyMap = new Map( IOS_SYSTEM_SURFACE_HOSTS.map((host) => [host.bundleId, host] as const), ); -/** The bundle id, if any, is a known observe-in-place system surface host. */ -export function isIosSystemSurfaceHost(bundleId: string | undefined): boolean { - return bundleId !== undefined && HOST_BY_BUNDLE_ID.has(bundleId); -} - -/** - * Refusal shown when a user tries to `open` a system surface host directly. These surfaces are not - * launched; while genuinely presented they appear in the session app's snapshots on their own, and - * are driven in place. Keyed by callers off `UNSUPPORTED_OPERATION`; the text is the agent-facing - * explanation. - */ -export function iosSystemSurfaceOpenRefusal(bundleId: string): string { - return `${bundleId} is a system-hosted surface (e.g. a web sign-in sheet) that cannot be opened directly — launching or activating it cancels what it presents. While it is on screen it already appears in this session's snapshots; read it and interact with it there without opening it.`; +/** The registered host for a bundle id, or undefined when it is not a system surface host. */ +export function iosSystemSurfaceHost( + bundleId: string | undefined, +): IosSystemSurfaceHost | undefined { + return bundleId === undefined ? undefined : HOST_BY_BUNDLE_ID.get(bundleId); } /** @@ -65,24 +64,55 @@ export type IosSystemSurfaceProvenance = Readonly<{ kind: IosSystemSurfaceKind; }>; +/** + * How the agent-facing sentences name each kind of surface. Exhaustive over the kind so a new host + * kind cannot ship without its wording. + */ +const SURFACE_NOUN: Readonly> = Object.freeze({ + 'web-auth': 'a system web sign-in sheet', + payment: 'the system Apple Pay sheet', +}); + +/** + * Every bundle id that reaches the sentences below came from the registry: the runner stamps only + * registered hosts, and the wire reader drops anything else. An unregistered id here is a + * programming error, not a state to describe. + */ +function surfaceNoun(bundleId: string): string { + const host = HOST_BY_BUNDLE_ID.get(bundleId); + if (!host) throw new Error(`${bundleId} is not a registered iOS system surface host`); + return SURFACE_NOUN[host.kind]; +} + +/** + * Refusal shown when a user tries to `open` a system surface host directly. These surfaces are not + * launched; while genuinely presented they appear in the session app's snapshots on their own, and + * are driven in place. Keyed by callers off `UNSUPPORTED_OPERATION`; the text is the agent-facing + * explanation. + */ +export function iosSystemSurfaceOpenRefusal(bundleId: string): string { + return `${bundleId} hosts ${surfaceNoun(bundleId)} and cannot be opened directly — launching or activating it cancels what it presents. While it is on screen it already appears in this session's snapshots; read it and interact with it there without opening it.`; +} + /** * The one agent-facing explanation for an iOS capture that faithfully shows an occluding system - * surface (a web sign-in sheet) instead of app content. Shared by the direct snapshot warning and - * every selector-backed consumer (find/wait/get/is) so the disclosure cannot silently drop on one - * route while surviving on another; generalizes the Android system-surface disclosure. + * surface instead of app content. Shared by the direct snapshot warning and every selector-backed + * consumer (find/wait/get/is) so the disclosure cannot silently drop on one route while surviving + * on another; generalizes the Android system-surface disclosure. */ -export const IOS_SYSTEM_SURFACE_DISCLOSURE = - 'A system web sign-in sheet is presented over the app, so this snapshot shows that sheet (hosted out of the app process). Its controls are real and interactive; complete or dismiss the sheet to return to app content.'; +export function iosSystemSurfaceDisclosure(bundleId: string): string { + return `This snapshot shows ${surfaceNoun(bundleId)} presented over the app (hosted out of the app process), not app content. Its controls are real and interactive; complete or dismiss the sheet to return to app content.`; +} /** * The agent-facing sentence for a surface TRANSITION between two captures — the post-action * observation's case, where the pre-action baseline and the capture taken after the action describe - * different surfaces. `to` is the surface the AFTER capture describes: a host bundle id when the - * sheet is now on screen (the standing disclosure applies verbatim), or `undefined` when the sheet - * has left and the capture shows app content again, which the standing sentence cannot say. + * different surfaces. When the AFTER capture is a sheet the standing disclosure applies verbatim; + * when it is app content again (`to` is {@link APP_SURFACE}) the sentence names the sheet that left, + * which the standing sentence cannot say. */ -export function iosSystemSurfaceTransitionDisclosure(to: string | undefined): string { - return to === undefined - ? 'A system web sign-in sheet was presented over the app before this action and is gone now, so this observation describes app content while the pre-action tree described that sheet.' - : IOS_SYSTEM_SURFACE_DISCLOSURE; +export function iosSystemSurfaceTransitionDisclosure(change: { from: string; to: string }): string { + return change.to === APP_SURFACE + ? `Before this action ${surfaceNoun(change.from)} was presented over the app and it is gone now, so this observation describes app content while the pre-action tree described that sheet.` + : iosSystemSurfaceDisclosure(change.to); } diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index a052b55952..4c02959798 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -196,9 +196,9 @@ export async function closeIosApp( * app-lifecycle facade's eager closure flat. */ async function assertNotSystemSurfaceHost(bundleId: string): Promise { - const { isIosSystemSurfaceHost, iosSystemSurfaceOpenRefusal } = + const { iosSystemSurfaceHost, iosSystemSurfaceOpenRefusal } = await import('@agent-device/contracts/ios-system-surface'); - if (!isIosSystemSurfaceHost(bundleId)) return; + if (!iosSystemSurfaceHost(bundleId)) return; throw new AppError('UNSUPPORTED_OPERATION', iosSystemSurfaceOpenRefusal(bundleId), { reason: 'system-surface-host-not-openable', appBundleId: bundleId, diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 0124077dde..1a59cbf80d 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -34,7 +34,7 @@ import { readAppleSnapshotResult, } from './runner/snapshot-presentation.ts'; import type { AppleRunnerSnapshotResult } from './runner/snapshot-presentation.ts'; -import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface'; +import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; export function createAppleInteractor( device: DeviceInfo, @@ -251,12 +251,13 @@ async function captureAppleRunnerSnapshot( /** * Agent-facing warnings for a runner capture: a legacy runner's message text when it carried no * quality verdict, and the shared disclosure when the capture describes an in-place system surface - * (e.g. the web sign-in sheet) rather than the app itself (#2438). + * (e.g. the web sign-in or Apple Pay sheet) rather than the app itself (#2438). */ function runnerSnapshotWarnings(result: AppleRunnerSnapshotResult): string[] { const warnings: string[] = []; if (!result.quality && result.message) warnings.push(result.message); - if (result.systemSurface) warnings.push(IOS_SYSTEM_SURFACE_DISCLOSURE); + if (result.systemSurface) + warnings.push(iosSystemSurfaceDisclosure(result.systemSurface.bundleId)); return warnings; } diff --git a/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts b/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts index be504dcd19..920417b4e8 100644 --- a/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/snapshot-presentation.test.ts @@ -81,6 +81,23 @@ test('a sparse capture of a presented system surface names the surface host', () }); }); +// The registry, not the wire, decides the kind: a payment host stamped with the wrong kind reads +// back as `payment`, and a bundle id the registry does not know is dropped rather than surfaced. +test('the wire reader takes surface kind from the registry and drops unregistered hosts', () => { + const mismatched = readAppleSnapshotResult({ + systemSurface: { bundleId: 'com.apple.PassbookUIService', kind: 'web-auth' }, + }); + assert.deepEqual(mismatched.systemSurface, { + bundleId: 'com.apple.PassbookUIService', + kind: 'payment', + }); + + const unregistered = readAppleSnapshotResult({ + systemSurface: { bundleId: 'com.example.notahost', kind: 'payment' }, + }); + assert.equal(unregistered.systemSurface, undefined); +}); + test('a sparse payload failing another invariant still carries the verdict', () => { const error = catchPresent({ nodes: [ diff --git a/packages/platform-apple/src/runner/snapshot-presentation.ts b/packages/platform-apple/src/runner/snapshot-presentation.ts index 8913c9c793..9f79b1e942 100644 --- a/packages/platform-apple/src/runner/snapshot-presentation.ts +++ b/packages/platform-apple/src/runner/snapshot-presentation.ts @@ -22,8 +22,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import type { RawSnapshotNode, SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; import { - isIosSystemSurfaceHost, - type IosSystemSurfaceKind, + iosSystemSurfaceHost, type IosSystemSurfaceProvenance, } from '@agent-device/contracts/ios-system-surface'; @@ -56,14 +55,12 @@ export function readAppleSnapshotResult( } function readSystemSurfaceProvenance(value: unknown): IosSystemSurfaceProvenance | undefined { - if (!isRecord(value)) return undefined; - const bundleId = value.bundleId; - const kind = value.kind; - // Trust only a bundle id the shared registry recognizes; an unknown value is dropped rather than - // surfaced, mirroring the wire-reader discipline elsewhere in this module. - if (typeof bundleId !== 'string' || !isIosSystemSurfaceHost(bundleId)) return undefined; - if (typeof kind !== 'string') return undefined; - return { bundleId, kind: kind as IosSystemSurfaceKind }; + if (!isRecord(value) || typeof value.bundleId !== 'string') return undefined; + // The shared registry is the authority for both fields: an unknown bundle id is dropped rather + // than surfaced, mirroring the wire-reader discipline elsewhere in this module, and the kind is + // read from the registry rather than trusted from the wire. + const host = iosSystemSurfaceHost(value.bundleId); + return host && { bundleId: host.bundleId, kind: host.kind }; } export function presentAppleRunnerSnapshot( diff --git a/packages/platform-apple/src/system-surface-presence.test.ts b/packages/platform-apple/src/system-surface-presence.test.ts index 93d437a697..aaa32ee279 100644 --- a/packages/platform-apple/src/system-surface-presence.test.ts +++ b/packages/platform-apple/src/system-surface-presence.test.ts @@ -60,7 +60,9 @@ test('the same host running for another device is absent', async () => { test('no host process at all is absent without reading any environment', async () => { stubProbes({ pgrep: NOT_RUNNING }); await expect(createSystemSurfacePresenceProbe()(sim)).resolves.toBe('absent'); - expect(mockRunCmd).toHaveBeenCalledOnce(); + // One process-table scan per registered host and not a single environment read. + expect(mockRunCmd).toHaveBeenCalledTimes(IOS_SYSTEM_SURFACE_HOSTS.length); + expect(mockRunCmd.mock.calls.every(([command]) => command === 'pgrep')).toBe(true); }); test('a non-simulator is absent without probing', async () => { diff --git a/src/commands/interaction/runtime/interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts index 546452836e..e2430e3fc2 100644 --- a/src/commands/interaction/runtime/interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -10,7 +10,7 @@ import { } from '../../../runtime.ts'; import type { Point, SnapshotState } from '@agent-device/kernel/snapshot'; import { summarizeAxEvidence } from '@agent-device/capture-kit/snapshot-evidence'; -import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface'; +import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { coveredByTabBarSnapshot, @@ -497,6 +497,7 @@ test('runtime press with verify reports changedFromBefore true when the post-act // still-foreground app, so a capture of the sheet and a capture of the app describe DIFFERENT // surfaces. `--verify` must not answer "did this change?" by comparing their node digests. const WEB_SIGN_IN_SHEET_BUNDLE_ID = 'com.apple.SafariViewService'; +const WEB_SIGN_IN_DISCLOSURE = iosSystemSurfaceDisclosure(WEB_SIGN_IN_SHEET_BUNDLE_ID); function webSignInSheetSnapshot(labels: string[]): SnapshotState { return { @@ -540,7 +541,7 @@ test('runtime press with verify discloses the surface change when a sign-in shee assert.deepEqual(result.evidence?.surfaceChange, { from: 'app', to: WEB_SIGN_IN_SHEET_BUNDLE_ID, - disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE, + disclosure: WEB_SIGN_IN_DISCLOSURE, }); // The transition is what changed, not a digest comparison between two different surfaces. assert.equal(result.evidence?.changedFromBefore, true); @@ -575,7 +576,7 @@ test('runtime press with verify reports the sheet leaving even when the two dige assert.equal(result.evidence?.surfaceChange?.to, 'app'); assert.match(result.evidence?.surfaceChange?.disclosure ?? '', /sign-in sheet/); // The sheet is gone, so the standing "is presented over the app" sentence cannot be the one used. - assert.notEqual(result.evidence?.surfaceChange?.disclosure, IOS_SYSTEM_SURFACE_DISCLOSURE); + assert.notEqual(result.evidence?.surfaceChange?.disclosure, WEB_SIGN_IN_DISCLOSURE); assert.equal(result.evidence?.changedFromBefore, true); }); diff --git a/src/commands/interaction/runtime/post-action-surface.test.ts b/src/commands/interaction/runtime/post-action-surface.test.ts index 4eb639e650..4b7026141b 100644 --- a/src/commands/interaction/runtime/post-action-surface.test.ts +++ b/src/commands/interaction/runtime/post-action-surface.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { summarizeAxEvidence } from '@agent-device/capture-kit/snapshot-evidence'; -import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface'; +import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; import { selector } from './selector-read-utils.ts'; import { buttonSnapshot, @@ -18,6 +18,7 @@ import { // within one surface — and, since diff presence is what issues refs, it would hand // the caller refs for that claim. const WEB_SIGN_IN_SHEET_BUNDLE_ID = 'com.apple.SafariViewService'; +const WEB_SIGN_IN_DISCLOSURE = iosSystemSurfaceDisclosure(WEB_SIGN_IN_SHEET_BUNDLE_ID); function webSignInSheetSnapshot(labels: string[]): SnapshotState { return { @@ -70,7 +71,7 @@ test('press --settle attaches no diff across an app-to-sheet surface change and assert.deepEqual(settle.surfaceChange, { from: 'app', to: WEB_SIGN_IN_SHEET_BUNDLE_ID, - disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE, + disclosure: WEB_SIGN_IN_DISCLOSURE, }); // No same-surface claim: no diff, so no issued refs, and no tail either. assert.equal(settle.diff, undefined); @@ -110,7 +111,7 @@ test('press --settle attaches no diff across a sheet-to-app surface change and d assert.match(settle.surfaceChange?.disclosure ?? '', /sign-in sheet/); // The sheet is gone, so the standing "is presented over the app" sentence cannot // be the one used. - assert.notEqual(settle.surfaceChange?.disclosure, IOS_SYSTEM_SURFACE_DISCLOSURE); + assert.notEqual(settle.surfaceChange?.disclosure, WEB_SIGN_IN_DISCLOSURE); assert.equal(settle.diff, undefined); assert.equal(settle.tail, undefined); assert.match(settle.hint ?? '', /different surfaces/); @@ -139,7 +140,7 @@ test('press --settle --verify reports one app-to-sheet surface change on both pa assert.deepEqual(result.evidence?.surfaceChange, { from: 'app', to: WEB_SIGN_IN_SHEET_BUNDLE_ID, - disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE, + disclosure: WEB_SIGN_IN_DISCLOSURE, }); assert.equal(result.evidence?.changedFromBefore, true); assert.equal(result.settle?.surfaceChange?.to, WEB_SIGN_IN_SHEET_BUNDLE_ID); diff --git a/src/commands/interaction/runtime/post-action-surface.ts b/src/commands/interaction/runtime/post-action-surface.ts index 23b173cb4f..aea3aaafb2 100644 --- a/src/commands/interaction/runtime/post-action-surface.ts +++ b/src/commands/interaction/runtime/post-action-surface.ts @@ -5,12 +5,16 @@ import type { PostActionSurfaceChange, SurfaceScopedNodes, } from '@agent-device/contracts/interaction'; -import { iosSystemSurfaceTransitionDisclosure } from '@agent-device/contracts/ios-system-surface'; +import { + APP_SURFACE, + iosSystemSurfaceTransitionDisclosure, +} from '@agent-device/contracts/ios-system-surface'; /** * The surface question every post-action observation owes (#2438): iOS serves an in-place system - * surface — a web sign-in sheet hosted out of the app's process — over a still-foreground app, so a - * capture of the sheet and a capture of the app describe DIFFERENT surfaces. Comparing their node + * surface — a web sign-in or Apple Pay sheet hosted out of the app's process — over a + * still-foreground app, so a capture of the sheet and a capture of the app describe DIFFERENT + * surfaces. Comparing their node * digests yields a meaningless "changed" verdict, and diffing them presents a whole-surface * replacement as an in-surface diff, with refs. * @@ -18,9 +22,6 @@ import { iosSystemSurfaceTransitionDisclosure } from '@agent-device/contracts/io * disclosure cannot hold on one route and drop on the other. */ -/** How a capture of ordinary app content names its surface in a {@link PostActionSurfaceChange}. */ -const APP_SURFACE = 'app'; - /** Mints the one carried value from a capture: the nodes together with the surface they describe. */ export function surfaceScopedNodes(snapshot: SnapshotState): SurfaceScopedNodes { return { @@ -41,11 +42,11 @@ export function resolvePostActionSurfaceChange( after: SurfaceScopedNodes, ): PostActionSurfaceChange | undefined { if (!baseline || baseline.surfaceBundleId === after.surfaceBundleId) return undefined; - return { + const surfaces = { from: baseline.surfaceBundleId ?? APP_SURFACE, to: after.surfaceBundleId ?? APP_SURFACE, - disclosure: iosSystemSurfaceTransitionDisclosure(after.surfaceBundleId), }; + return { ...surfaces, disclosure: iosSystemSurfaceTransitionDisclosure(surfaces) }; } /** diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index d8caffa95b..fa0eb55985 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -2,7 +2,8 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { - IOS_SYSTEM_SURFACE_DISCLOSURE, + APP_SURFACE, + iosSystemSurfaceDisclosure, iosSystemSurfaceTransitionDisclosure, type IosSystemSurfaceProvenance, } from '@agent-device/contracts/ios-system-surface'; @@ -307,6 +308,11 @@ const WEB_SIGN_IN_SHEET: IosSystemSurfaceProvenance = { bundleId: 'com.apple.SafariViewService', kind: 'web-auth', }; +const WEB_SIGN_IN_DISCLOSURE = iosSystemSurfaceDisclosure(WEB_SIGN_IN_SHEET.bundleId); +const WEB_SIGN_IN_DEPARTED_DISCLOSURE = iosSystemSurfaceTransitionDisclosure({ + from: WEB_SIGN_IN_SHEET.bundleId, + to: APP_SURFACE, +}); /** Five nodes, so a settled sheet clears the tiny-tree readiness hint and the hint under test is * the cross-surface one. No Application root: the sheet is hosted out of the app's process. */ @@ -389,7 +395,7 @@ test('scroll --settle attaches no diff across an app-to-sheet surface change and expectCrossSurfaceSettle(expectOkData(response).settle as SettlePayload, { from: 'app', to: WEB_SIGN_IN_SHEET.bundleId, - disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE, + disclosure: WEB_SIGN_IN_DISCLOSURE, }); // Disclosed, not hidden: the settled sheet still becomes the stored observation a follow-up // snapshot reads — and the surface identity the NEXT command's baseline is built from. @@ -423,9 +429,9 @@ test('scroll --settle attaches no diff across a sheet-to-app surface change and to: 'app', // The sheet is gone, so the standing "is presented over the app" sentence cannot be the one // used — the transition disclosure has to say it left. - disclosure: iosSystemSurfaceTransitionDisclosure(undefined), + disclosure: WEB_SIGN_IN_DEPARTED_DISCLOSURE, }); - expect(settle.surfaceChange?.disclosure).not.toBe(IOS_SYSTEM_SURFACE_DISCLOSURE); + expect(settle.surfaceChange?.disclosure).not.toBe(WEB_SIGN_IN_DISCLOSURE); expect(settle.surfaceChange?.disclosure).toMatch(/sign-in sheet/); const stored = expectNoPublishedRefFrame(sessionStore, sessionName); expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined(); @@ -459,7 +465,7 @@ test('back --settle attaches no diff across an app-to-sheet surface change and d expectCrossSurfaceSettle(data.settle as SettlePayload, { from: 'app', to: WEB_SIGN_IN_SHEET.bundleId, - disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE, + disclosure: WEB_SIGN_IN_DISCLOSURE, }); const stored = expectNoPublishedRefFrame(sessionStore, sessionName); expect(stored.snapshot?.iosSystemSurfaceBundleId).toBe(WEB_SIGN_IN_SHEET.bundleId); @@ -494,7 +500,7 @@ test('back --settle attaches no diff across a sheet-to-app surface change and di expectCrossSurfaceSettle(data.settle as SettlePayload, { from: WEB_SIGN_IN_SHEET.bundleId, to: 'app', - disclosure: iosSystemSurfaceTransitionDisclosure(undefined), + disclosure: WEB_SIGN_IN_DEPARTED_DISCLOSURE, }); const stored = expectNoPublishedRefFrame(sessionStore, sessionName); expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined(); diff --git a/src/daemon/__tests__/system-surface-disclosure.test.ts b/src/daemon/__tests__/system-surface-disclosure.test.ts index 5113fd718b..15dc5e509e 100644 --- a/src/daemon/__tests__/system-surface-disclosure.test.ts +++ b/src/daemon/__tests__/system-surface-disclosure.test.ts @@ -6,7 +6,7 @@ import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; import { dispatchWaitViaRuntime } from '../wait-runtime.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/android-system-surface-disclosure'; -import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface'; +import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { @@ -266,10 +266,12 @@ test('sessionless wait timeout still discloses the occluding system surface', as // The runner stamps `systemSurface` on a capture it served from the sheet; buildSnapshotState turns // that into `iosSystemSurfaceBundleId`, which selector routes must carry and disclose. +const WEB_SIGN_IN_SHEET_BUNDLE_ID = 'com.apple.SafariViewService'; +const WEB_SIGN_IN_DISCLOSURE = iosSystemSurfaceDisclosure(WEB_SIGN_IN_SHEET_BUNDLE_ID); const SHEET_SNAPSHOT_DATA = { backend: 'xctest', producer: 'apple-runner', - systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + systemSurface: { bundleId: WEB_SIGN_IN_SHEET_BUNDLE_ID, kind: 'web-auth' }, nodes: [ { index: 0, @@ -320,7 +322,7 @@ test('mutating find on an in-place system surface discloses it on the found outc expect(response?.ok).toBe(true); if (!response?.ok) return; expect(String((response.data as Record).warning)).toContain( - IOS_SYSTEM_SURFACE_DISCLOSURE, + WEB_SIGN_IN_DISCLOSURE, ); }); @@ -346,25 +348,23 @@ test('mutating find that misses on an in-place system surface still discloses it expect(response?.ok).toBe(false); if (response?.ok) return; - expect(String(response?.error.details?.hint)).toContain(IOS_SYSTEM_SURFACE_DISCLOSURE); + expect(String(response?.error.details?.hint)).toContain(WEB_SIGN_IN_DISCLOSURE); }); test('the shared disclosure helper reports an iOS system surface on both outcomes', () => { const ok = withSystemSurfaceDisclosure( { ok: true, data: { found: true } }, - { iosSystemSurfaceBundleId: 'com.apple.SafariViewService' }, + { iosSystemSurfaceBundleId: WEB_SIGN_IN_SHEET_BUNDLE_ID }, ); expect(ok.ok).toBe(true); if (!ok.ok) return; - expect(String((ok.data as Record).warning)).toContain( - IOS_SYSTEM_SURFACE_DISCLOSURE, - ); + expect(String((ok.data as Record).warning)).toContain(WEB_SIGN_IN_DISCLOSURE); const failed = withSystemSurfaceDisclosure( { ok: false, error: { code: 'NOT_FOUND', message: 'no match' } }, - { iosSystemSurfaceBundleId: 'com.apple.SafariViewService' }, + { iosSystemSurfaceBundleId: WEB_SIGN_IN_SHEET_BUNDLE_ID }, ); expect(failed.ok).toBe(false); if (failed.ok) return; - expect(String(failed.error.details?.hint)).toContain(IOS_SYSTEM_SURFACE_DISCLOSURE); + expect(String(failed.error.details?.hint)).toContain(WEB_SIGN_IN_DISCLOSURE); }); diff --git a/src/daemon/system-surface-disclosure.ts b/src/daemon/system-surface-disclosure.ts index 63d83cecb1..5c7bdeb34b 100644 --- a/src/daemon/system-surface-disclosure.ts +++ b/src/daemon/system-surface-disclosure.ts @@ -1,13 +1,13 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { systemSurfaceDisclosure } from '@agent-device/contracts/android-system-surface-disclosure'; -import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface'; +import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; import type { DaemonResponse } from './daemon-request.ts'; /** * Append the occluding-system-surface disclosure to a selector-route response whose consumed * snapshot was a system surface: an Android notification shade / quick settings, or an iOS in-place - * web sign-in sheet (#2438). Both found and not-found outcomes must explain that app content is - * occluded: a match found inside the surface is not app content, and a miss is expected while the + * system sheet such as web sign-in or Apple Pay (#2438). Both found and not-found outcomes must + * explain that app content is occluded: a match found inside the surface is not app content, and a miss is expected while the * surface covers the app. */ export function withSystemSurfaceDisclosure( @@ -15,7 +15,7 @@ export function withSystemSurfaceDisclosure( snapshot: Pick | undefined, ): DaemonResponse { const disclosure = snapshot?.iosSystemSurfaceBundleId - ? IOS_SYSTEM_SURFACE_DISCLOSURE + ? iosSystemSurfaceDisclosure(snapshot.iosSystemSurfaceBundleId) : systemSurfaceDisclosure(snapshot); if (!disclosure) return response; if (response.ok) { diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 63f3972f0e..af80cc3c6e 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -221,7 +221,7 @@ const postActionSurfaceChangeSchema: JsonSchema = objectSchema( disclosure: stringSchema('Agent-facing sentence explaining the surface transition.'), }, ['from', 'to', 'disclosure'], - 'Present when an in-place system surface (web sign-in sheet) was presented over the app, or left it.', + 'Present when an in-place system surface (web sign-in or Apple Pay sheet) was presented over the app, or left it.', ); // InteractionEvidence (packages/contracts/src/interaction.ts) — opt-in `--verify` cheap From aec682a80478c9c8292d12f363373148a8144cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 15 Sep 2026 19:10:22 +0200 Subject: [PATCH 2/2] test(ios): pin the PassbookUIService presence probe and record the Apple Pay change Key the Simulator presence-probe test's pgrep stub on each host's processExecutable instead of answering every pgrep call alike, and add a case where SafariViewService is absent and only a device-scoped PassbookUIService pid is present, asserting the resolved host by registry lookup (kind/bundleId) rather than array index. Without this the positive-presence test always matched SafariViewService by construction and could not tell the two hosts apart. Add the three Unreleased CHANGELOG entries for #2639: bare type/fill now works on the Apple Pay sheet, Simulator captures also probe for a lingering PassbookUIService host, and the iOS system-surface disclosure sentence now names which kind of sheet is on screen. Rewrap the unwrapped comment/doc lines this PR added to the surrounding 100-column style in system-surface-disclosure.ts, ADR 0004, and the test-app README, with no wording changes. --- CHANGELOG.md | 11 ++++ .../adr/0004-ios-snapshot-backend-strategy.md | 16 ++--- examples/test-app/README.md | 6 +- .../src/system-surface-presence.test.ts | 62 +++++++++++++++---- src/daemon/system-surface-disclosure.ts | 4 +- 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4307468822..602acb7ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Added (ios): `type` and `fill` work in the Apple Pay sheet on iOS Simulator instead of failing + with `TEXT_INPUT_NOT_FOCUSED`. `com.apple.PassbookUIService` is served in place like the web + sign-in host (#2438). +- Changed (ios): Simulator captures also probe for `com.apple.PassbookUIService`. It can keep + running after the Apple Pay sheet closes, so a later capture in that app can report + `system-surface-host-lingering` while the process stays alive. +- Changed (ios): the in-place system surface disclosure now names the sheet kind. The web sign-in + sentence changed from "A system web sign-in sheet is presented over the app, so this snapshot + shows that sheet (hosted out of the app process)." to "This snapshot shows a system web sign-in + sheet presented over the app (hosted out of the app process), not app content". The payment host + says "the system Apple Pay sheet" instead. - Fixed (android): a chunked `record stop` (recordings over 170 s) no longer warns that screenrecord stopped before record stop at the 180 s limit. Rotation always ends every earlier chunk before stop, so the warning now fires only when the last chunk's recorder had already exited. diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 998edd4ba0..29d5c27b26 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -360,14 +360,14 @@ teardown) unrepresentable for the delegated-auth flow, because the session never Captures of a system surface carry a response-level `systemSurface` provenance and the shared `iosSystemSurfaceDisclosure`, worded per host kind, so the agent is told the controls belong to a system sheet (web sign-in, Apple Pay) rather than the app. They are also lineaged to the host rather -than the app, so their comparison identity differs from an app capture's by construction: every consumer that asks "are these two captures the -same presentation" refuses a cross-surface pair through ordinary key equality, and no comparison -site carries a surface check of its own. Physical devices always use the runner, so the in-place -serve applies there without a route change; the Simulator route probe is the only -Simulator-specific piece. - -The Apple Pay host (`com.apple.PassbookUIService`) joined the registry for text entry as much as -for snapshots. Its billing, shipping, and contact forms hold text fields the session app's tree cannot +than the app, so their comparison identity differs from an app capture's by construction: every +consumer that asks "are these two captures the same presentation" refuses a cross-surface pair +through ordinary key equality, and no comparison site carries a surface check of its own. Physical +devices always use the runner, so the in-place serve applies there without a route change; the +Simulator route probe is the only Simulator-specific piece. + +The Apple Pay host (`com.apple.PassbookUIService`) joined the registry for text entry as much as for +snapshots. Its billing, shipping, and contact forms hold text fields the session app's tree cannot resolve, and a bare `type` addressed to the app process never sees that keyboard. Addressing the host in place is what lets the runner's first-responder route type into them; no text-entry branch changed for it. diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 1cdc3f2aad..232b70017c 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -96,9 +96,9 @@ field itself never appears in the accessibility tree and only the keyboard prove `automation-flattened-value` mirrors what was typed. `automation-open-apple-pay` (iOS only, native module `modules/apple-pay-lab`) presents the system Apple Pay sheet requiring a billing address plus contact email and phone; those forms are hosted out of process in `com.apple.PassbookUIService`, and -`automation-apple-pay-result` reports `authorized` or `dismissed` once the sheet closes. CI repacks JavaScript-only changes into the -cached Release app without starting Metro; native configuration changes intentionally produce one -new fingerprinted build that all simulator consumers share. +`automation-apple-pay-result` reports `authorized` or `dismissed` once the sheet closes. CI repacks +JavaScript-only changes into the cached Release app without starting Metro; native configuration +changes intentionally produce one new fingerprinted build that all simulator consumers share. ### iOS simulator diff --git a/packages/platform-apple/src/system-surface-presence.test.ts b/packages/platform-apple/src/system-surface-presence.test.ts index aaa32ee279..75a6c1e5cd 100644 --- a/packages/platform-apple/src/system-surface-presence.test.ts +++ b/packages/platform-apple/src/system-surface-presence.test.ts @@ -24,10 +24,33 @@ const sim = { type ProbeReply = { exitCode: number; stdout: string }; -/** Routes the two probe commands independently so each failure mode can be exercised alone. */ -function stubProbes(replies: { pgrep?: ProbeReply | Error; ps?: ProbeReply | Error }): void { - mockRunCmd.mockImplementation(async (command: string) => { - const reply = command === 'pgrep' ? replies.pgrep : replies.ps; +const SAFARI_HOST = IOS_SYSTEM_SURFACE_HOSTS.find( + (host) => host.bundleId === 'com.apple.SafariViewService', +)!; +const PASSBOOK_HOST = IOS_SYSTEM_SURFACE_HOSTS.find((host) => host.kind === 'payment')!; + +/** Literals, not registry reads, so a drifted registry executable fails the per-host cases. */ +const SAFARI_EXECUTABLE = 'SafariViewService.app/SafariViewService'; +const PASSBOOK_EXECUTABLE = 'PassbookUIService.app/PassbookUIService'; + +/** + * Routes the two probe commands independently so each failure mode can be exercised alone. + * `pgrepByExecutable` answers per `pgrep -f` target; `pgrep` answers every target alike. + */ +function stubProbes(replies: { + pgrep?: ProbeReply | Error; + pgrepByExecutable?: Readonly>; + ps?: ProbeReply | Error; +}): void { + mockRunCmd.mockImplementation(async (command: string, args: string[] = []) => { + if (command === 'pgrep') { + const executable = args[1] ?? ''; + const reply = replies.pgrepByExecutable?.[executable] ?? replies.pgrep; + if (reply === undefined) throw new Error(`unexpected pgrep target ${executable}`); + if (reply instanceof Error) throw reply; + return { exitCode: reply.exitCode, stdout: reply.stdout, stderr: '' }; + } + const reply = replies.ps; if (reply === undefined) throw new Error(`unexpected probe command ${command}`); if (reply instanceof Error) throw reply; return { exitCode: reply.exitCode, stdout: reply.stdout, stderr: '' }; @@ -37,10 +60,13 @@ function stubProbes(replies: { pgrep?: ProbeReply | Error; ps?: ProbeReply | Err const RUNNING = { exitCode: 0, stdout: '900\n' } as const; const NOT_RUNNING = { exitCode: 1, stdout: '' } as const; /** The verdict a matched host produces: the host travels with it, to become the capture's lineage. */ -const PRESENT = { kind: 'present', host: IOS_SYSTEM_SURFACE_HOSTS[0]! } as const; -const scopedTo = (udid: string): ProbeReply => ({ +const SAFARI_PRESENT = { kind: 'present', host: SAFARI_HOST } as const; +const scopedTo = ( + udid: string, + executable: string = SAFARI_HOST.processExecutable, +): ProbeReply => ({ exitCode: 0, - stdout: `/…/SafariViewService.app/SafariViewService SIMULATOR_UDID=${udid}`, + stdout: `/…/${executable} SIMULATOR_UDID=${udid}`, }); beforeEach(() => { @@ -49,7 +75,21 @@ beforeEach(() => { test('a host process scoped to this device is present, and names the host it matched', async () => { stubProbes({ pgrep: RUNNING, ps: scopedTo('UDID-1') }); - await expect(createSystemSurfacePresenceProbe()(sim)).resolves.toEqual(PRESENT); + await expect(createSystemSurfacePresenceProbe()(sim)).resolves.toEqual(SAFARI_PRESENT); +}); + +test('a device-scoped PassbookUIService pid present while SafariViewService is absent resolves to the payment host', async () => { + stubProbes({ + pgrepByExecutable: { + [SAFARI_EXECUTABLE]: NOT_RUNNING, + [PASSBOOK_EXECUTABLE]: RUNNING, + }, + ps: scopedTo('UDID-1', PASSBOOK_EXECUTABLE), + }); + await expect(createSystemSurfacePresenceProbe()(sim)).resolves.toEqual({ + kind: 'present', + host: PASSBOOK_HOST, + }); }); test('the same host running for another device is absent', async () => { @@ -113,7 +153,7 @@ test('absence is not cached: a sheet opening within the TTL is seen immediately' stubProbes({ pgrep: RUNNING, ps: scopedTo('UDID-1') }); clock += 10; // far inside the memo TTL - await expect(probe(sim)).resolves.toEqual(PRESENT); + await expect(probe(sim)).resolves.toEqual(SAFARI_PRESENT); }); test('unknown is not cached either', async () => { @@ -124,7 +164,7 @@ test('unknown is not cached either', async () => { stubProbes({ pgrep: RUNNING, ps: scopedTo('UDID-1') }); clock += 10; - await expect(probe(sim)).resolves.toEqual(PRESENT); + await expect(probe(sim)).resolves.toEqual(SAFARI_PRESENT); }); test('a positive observation is memoized within the TTL and re-probed after it', async () => { @@ -133,7 +173,7 @@ test('a positive observation is memoized within the TTL and re-probed after it', stubProbes({ pgrep: RUNNING, ps: scopedTo('UDID-1') }); await probe(sim); const callsAfterFirst = mockRunCmd.mock.calls.length; - await expect(probe(sim)).resolves.toEqual(PRESENT); + await expect(probe(sim)).resolves.toEqual(SAFARI_PRESENT); expect(mockRunCmd.mock.calls.length).toBe(callsAfterFirst); clock += 2_000; // past the TTL diff --git a/src/daemon/system-surface-disclosure.ts b/src/daemon/system-surface-disclosure.ts index 5c7bdeb34b..aa0c7b5170 100644 --- a/src/daemon/system-surface-disclosure.ts +++ b/src/daemon/system-surface-disclosure.ts @@ -7,8 +7,8 @@ import type { DaemonResponse } from './daemon-request.ts'; * Append the occluding-system-surface disclosure to a selector-route response whose consumed * snapshot was a system surface: an Android notification shade / quick settings, or an iOS in-place * system sheet such as web sign-in or Apple Pay (#2438). Both found and not-found outcomes must - * explain that app content is occluded: a match found inside the surface is not app content, and a miss is expected while the - * surface covers the app. + * explain that app content is occluded: a match found inside the surface is not app content, and a + * miss is expected while the surface covers the app. */ export function withSystemSurfaceDisclosure( response: DaemonResponse,