Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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.
enum SystemSurfaceHostKind: String {
case webAuth = "web-auth"
}

struct SystemSurfaceHost: Equatable {
let bundleId: String
let kind: SystemSurfaceHostKind
}

enum SystemSurfaceHostRegistry {
static let hosts: [SystemSurfaceHost] = [
SystemSurfaceHost(bundleId: "com.apple.SafariViewService", kind: .webAuth)
]

static func host(forBundleId bundleId: String?) -> SystemSurfaceHost? {
guard let bundleId else { return nil }
return hosts.first { $0.bundleId == bundleId }
}

static func isSystemSurfaceHost(_ bundleId: String?) -> Bool {
host(forBundleId: bundleId) != nil
}
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
import XCTest

private struct SystemSurfaceHostFixture: Decodable {
struct Host: Decodable {
let bundleId: String
let kind: String
}
let hosts: [Host]
}

extension RunnerTests {
func testSystemSurfaceHostRegistryMirrorsGoldenFixture() throws {
let fixtureURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // AgentDeviceRunnerUITests
.deletingLastPathComponent() // AgentDeviceRunner
.deletingLastPathComponent() // runner
.deletingLastPathComponent() // apple
.deletingLastPathComponent() // repo root
.appendingPathComponent("contracts")
.appendingPathComponent("fixtures")
.appendingPathComponent("ios-system-surface-hosts.json")
let fixture = try JSONDecoder().decode(
SystemSurfaceHostFixture.self,
from: Data(contentsOf: fixtureURL)
)
let registry = SystemSurfaceHostRegistry.hosts.map { [$0.bundleId, $0.kind.rawValue] }
let golden = fixture.hosts.map { [$0.bundleId, $0.kind] }
XCTAssertEqual(registry, golden, "SystemSurfaceHostRegistry drifted from the golden fixture")
}

func testSystemSurfaceHostRegistryRecognizesRegisteredHosts() {
XCTAssertTrue(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.apple.SafariViewService"))
XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.example.app"))
XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost(nil))
XCTAssertEqual(
SystemSurfaceHostRegistry.host(forBundleId: "com.apple.SafariViewService")?.kind,
.webAuth
)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,8 @@ extension RunnerTests {

struct ActiveCommandContext {
let app: XCUIApplication
/// Set when `app` is a system surface served in place over the still-bound session app (#2438).
var systemSurface: SystemSurfaceHost? = nil
}

enum ActiveCommandPreparation {
Expand Down Expand Up @@ -1342,7 +1344,11 @@ extension RunnerTests {
case .response(let response):
return response
case .context(let context):
return try executeSnapshotPrepared(command: command, activeApp: context.app)
return try executeSnapshotPrepared(
command: command,
activeApp: context.app,
systemSurface: context.systemSurface
)
}
}

Expand All @@ -1364,15 +1370,25 @@ extension RunnerTests {
)
}

private func executeSnapshotPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
private func executeSnapshotPrepared(
command: Command,
activeApp: XCUIApplication,
systemSurface: SystemSurfaceHost? = nil
) throws -> Response {
let options = Self.presentationOptions(from: command)
do {
let payload: DataPayload
var payload: DataPayload
if options.raw {
payload = try snapshotRaw(app: activeApp, options: options)
} else {
payload = try snapshotFast(app: activeApp, options: options)
}
if let systemSurface {
payload.systemSurface = SystemSurfaceProvenancePayload(
bundleId: systemSurface.bundleId,
kind: systemSurface.kind.rawValue
)
}
setNeedsPostSnapshotInteractionDelay()
return Response(ok: true, data: payload)
} catch let failure as SnapshotCaptureFailure {
Expand Down Expand Up @@ -1575,10 +1591,20 @@ extension RunnerTests {
routeToSpringboard: Bool = false
) -> ActiveCommandPreparation {
var activeApp = currentApp ?? app
var systemSurface: SystemSurfaceHost? = nil
if routeToSpringboard {
activeApp = springboard
} else if shouldSkipAppActivationPreflight(command) {
activeApp = resolveAppWithoutActivation(command: command)
} else if let presented = presentedSystemSurfaceHost() {
// Serve and drive the presented surface IN PLACE: never activate it (that cancels what it
// presents) and never adopt it as the cached session target, so once it is gone the next
// command resolves back to the still-bound session app (#2438).
activeApp = presented.app
systemSurface = presented.host
if isInteractionCommand(command.command) {
applyInteractionStabilizationIfNeeded()
}
} else if !isRunnerLifecycleCommand(command.command) {
let normalizedBundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -1639,7 +1665,25 @@ extension RunnerTests {
applyInteractionStabilizationIfNeeded()
}
}
return .context(ActiveCommandContext(app: activeApp))
return .context(ActiveCommandContext(app: activeApp, systemSurface: systemSurface))
}

/// A registered system surface host that is genuinely on screen, or nil. Presence is foreground
/// state, not tree content: a torn-down host still serves a rich tree, and it can only be
/// foreground-with-a-stale-tree if something activated it, which the open guard refuses. `state`
/// never activates and is cheap when the host is absent. See docs/adr/0004.
private func presentedSystemSurfaceHost() -> (host: SystemSurfaceHost, app: XCUIApplication)? {
#if os(iOS)
for host in SystemSurfaceHostRegistry.hosts {
let candidate = XCUIApplication(bundleIdentifier: host.bundleId)
if candidate.state == .runningForeground {
return (host, candidate)
}
}
return nil
#else
return nil
#endif
}

func executeOnMainPrepared(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ struct DataPayload: Codable {
var truncated: Bool?
var qualityPayload: SnapshotQualityPayload? = nil
var snapshotQuality: SnapshotQuality?
/// Set when the capture describes an in-place system surface, not the app itself (#2438).
var systemSurface: SystemSurfaceProvenancePayload?
var gestureStartUptimeMs: Double?
var gestureEndUptimeMs: Double?
var x: Double?
Expand Down Expand Up @@ -275,6 +277,12 @@ struct DataPayload: Codable {
var sequenceResults: [SequenceStepResult]?
}

/// `kind` mirrors the TS `IosSystemSurfaceKind` (e.g. "web-auth").
struct SystemSurfaceProvenancePayload: Codable {
let bundleId: String
let kind: String
}

struct SnapshotQualityPayload: Codable {
let nodes: [PresentedNode]
let truncated: Bool
Expand Down
11 changes: 11 additions & 0 deletions contracts/fixtures/ios-system-surface-hosts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"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.",
"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)."
}
]
}
34 changes: 34 additions & 0 deletions docs/adr/0004-ios-snapshot-backend-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,37 @@ When adding new iOS snapshot behavior, maintainers should first decide which str
change tries to make regular snapshots fast by dropping visible controls behind a node budget, or
tries to make raw snapshots safe by silently truncating, it is probably crossing strategy
boundaries.

## Amendment: in-place system surfaces (issue #2438)

Some UI is presented out of the app's process by a system bundle — `com.apple.SafariViewService`,
which hosts `ASWebAuthenticationSession` and `SFSafariViewController` for delegated OAuth/OIDC
sign-in. Two facts, both verified live on the iOS 26.2 Simulator, shape how it is captured:

- The surface dies if activated. `XCUIApplication.activate()` or `simctl launch` on the host cancels
the authentication session and blacks the view. So the host must be observed and driven **in
place**, never activated, and `open` refuses to launch a registered host.
- The local host AX bridge cannot see it. While the sheet is up the app remains the AX `primaryApp`,
so the bridge serves the (occluded) app tree as if healthy. Only the XCTest runner, addressing the
host by bundle id, can read and drive the sheet.

Decision. A closed registry names these hosts (`contracts/fixtures/ios-system-surface-hosts.json`,
mirrored by the TypeScript and Swift registries under a parity test). When a registered host is
genuinely presented, the runner serves and drives it in place and never adopts it as the cached
session target; the session binding stays on the app, so once the surface is gone the next command
resolves back to the app. On the Simulator a cheap, device-scoped host-side probe (a registered
host process running for the device) routes the capture to the runner instead of the bridge; when no
host is running the bridge fast path is untouched.

Presence is `XCUIApplication.state == .runningForeground`, not tree content. The live spike showed a
torn-down host still serving a *richer* tree than a live one, so content heuristics cannot separate
live from dead; foreground state can. Crucially, the only way a host is foreground with a stale tree
is if it was activated or relaunched — which the open guard and the in-place policy both refuse — so
this fix and the never-activate guard are one design: the guard is what makes the foreground
predicate sound. This also makes issue #2438's second bug (a stale tree served confidently after
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. 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-device",
"version": "0.21.0",
"version": "0.21.1",
"description": "Mobile app automation and verification for AI coding agents. CLI, MCP server, and typed Node.js API for iOS, Android, HarmonyOS, TV, web, macOS, and Linux.",
"mcpName": "io.github.callstack/agent-device",
"license": "MIT",
Expand Down
3 changes: 3 additions & 0 deletions packages/capture-kit/src/snapshot-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { coveredAndroidReplacementNodeIndexes } from './snapshot/android-replace
import { scopeSnapshotNodes } from './snapshot-desktop-projection.ts';
import { normalizeSnapshotTree, pruneGroupNodes } from './snapshot-tree-ingestion.ts';
import { iosSnapshotComparisonIdentityKey } from './ios-snapshot-planning.ts';
import type { IosSystemSurfaceProvenance } from '@agent-device/contracts/ios-system-surface';
import type { IosSnapshotComparisonIdentity } from '@agent-device/contracts/ios-snapshot';

/**
Expand All @@ -43,6 +44,7 @@ export function buildSnapshotState(
truncated?: boolean;
quality?: unknown;
comparisonIdentity?: IosSnapshotComparisonIdentity;
systemSurface?: IosSystemSurfaceProvenance;
} & SnapshotCaptureProvenance,
flags:
| (Pick<CommandFlags, 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw'> &
Expand Down Expand Up @@ -78,6 +80,7 @@ export function buildSnapshotState(
...(data.comparisonIdentity
? { comparisonKey: iosSnapshotComparisonIdentityKey(data.comparisonIdentity) }
: {}),
...(data.systemSurface ? { iosSystemSurfaceBundleId: data.systemSurface.bundleId } : {}),
presentationKey: buildSnapshotPresentationKey(snapshotPresentationOptionsFromFlags(flags)),
// Only broad Android snapshots become freshness baselines. If the user asked for a scoped
// or filtered view, preserve that output contract but avoid pretending it is safe for
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@
"types": "./src/ios-snapshot.ts",
"default": "./src/ios-snapshot.ts"
},
"./ios-system-surface": {
"types": "./src/ios-system-surface.ts",
"default": "./src/ios-system-surface.ts"
},
"./interactor-operation-catalog": {
"types": "./src/interactor-operation-catalog.ts",
"default": "./src/interactor-operation-catalog.ts"
Expand Down
Loading
Loading