feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) - #2448
feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438)#2448thymikee wants to merge 16 commits into
Conversation
…2438) iOS apps that sign in via ASWebAuthenticationSession present the identity provider in com.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AX primaryApp. Serve and drive the sheet in place. A closed registry names the host (shared by the TypeScript and Swift sides under a parity test); the runner reads and drives it without activation and never adopts it as the session target; and the Simulator route detects a running host with a cheap device-scoped ps probe and takes the runner path, since the bridge would serve the occluded app tree as if healthy. open refuses to launch a registered host, and captures carry a system-surface disclosure. Presence is foreground state, not tree content: a torn-down host serves a richer tree than a live one, so content heuristics cannot tell them apart. The never-activate guard is what keeps the foreground predicate sound, which also makes the stale-tree failure mode unrepresentable for this flow. Closes #2438
Size Report
Startup median (7 runs, lower is better):
|
|
|
There are still correctness gaps at 0cde7a4. The presence probe caches absence for one second and treats ps failures as absence. A sheet opened just after an app capture, or while ps fails, therefore falls through to the bridge and returns the occluded app tree. Cache only positive presence, route unknown results to the runner, and test absent-to-present within the TTL plus probe failure. The explicit-URL branches in openIosApp return before the new host guard. open com.apple.SafariViewService https://example.com can still launch the host and cancel authentication. Apply the refusal before every resolved-host launch or termination, and test the URL variants. The provenance is reduced to a warning instead of reaching comparison identity. App and sheet captures can therefore enter legacy same-presentation matching, including recorded-tap failure corroboration. #2450 needs to land with this change, or comparison across this boundary must be explicitly refused. Selector responses also drop the disclosure because they only read Android systemSurfaceOnly; include #2451 or narrow the advertised contract explicitly. Coverage fails on a related import-closure regression: app-lifecycle-facade grows from 120 to 121 modules through the new static import. Fix the import boundary without raising the budget. The iOS failure is an initial fixture-app capture stall, which looks unrelated to the auth route. Please itemize the +8.1 kB unpacked growth and briefly record the smaller design considered. The reported live sign-in flow is useful evidence, but does not cover the failure paths above. |
Presence probe: absence and probe failure are no longer reported as "no surface". The probe returns present/absent/unknown and the route takes the runner for anything but a proven absent, so a sheet opened between two captures, or a probe that cannot answer, can no longer fall through to a bridge capture that would answer confidently from the occluded app tree. Only a positive observation is memoized. The probe now matches with pgrep and reads only a matched pid's environment, which is ~3x cheaper than the previous full process-environment dump and stops copying every process's environment. Open guard: the refusal moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check can no longer launch the host. Terminating a host is refused too, since that cancels the presented session just as launching it does. Comparison: the surface identity now reaches SnapshotState, and tap-failure corroboration refuses outright when a baseline and a post-action capture disagree about it, instead of letting app and sheet captures meet in legacy same-presentation matching. Selector routes disclose an iOS system surface through the shared disclosure seam rather than reading only the Android field. The contracts import in the launch path is deferred so the app-lifecycle facade's eager closure stays flat, and the runner's comment prose is trimmed because apple/runner ships to npm as uncompiled source.
The probe shelled out with runCmd, so every eligible capture spawned a real process even in provider-backed tests that stub the Apple tool seam — 17 real spawns in one scenario file, which is both wasted work and added latency on timing-sensitive settle paths. It now goes through runAppleToolCommand like the sibling ps probe, so a stubbed provider answers instead of spawning.
Routing an unprovable probe to the runner is right, but the early return also skipped runFallback, so the response lost its warning and kept an identity that could still be compared against a bridge publication. An unknown probe now falls back through the same disclosed path as a bridge failure, with its own reason.
|
Addressed at Presence probe. It now returns Open guard. Moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check are covered — parameterized tests assert all four plus that no simctl command ever names the host bundle. Terminate is refused too, since that cancels the session just as launching does. Provenance. #2450 and #2451 are folded in rather than deferred. The surface identity reaches Import closure. The contracts import in the launch path is deferred, so Two more found while verifying. The probe used iOS smoke. Pre-existing, not this PR. The identical signature ( Size, itemized (measured at the reviewed commit). +4,273 B is Swift: Smaller design considered. Collapsing the contracts module into an existing one saves only ~200 B, because most of that chunk is the irreducible refusal/disclosure strings — not worth conflating the registry with an unrelated module. The real lever was Swift comment prose, which ships uncompiled: I trimmed the new blocks to pointers at the ADR/TS twin. That also turned up a repo-wide finding — comments are 16.6% of the 446 kB of shipped runner Swift (~74 kB) — filed as #2461 with a PR in flight. Gates: |
|
Still needs changes at 00e4185:
The current size report is +10.6 kB unpacked, while the explanation accounts for +8.1 kB. Please reconcile the remaining growth against this head. CI is green, but these code gaps remain. |
…cope A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an unreadable or truncated environment could route a live sheet to the occluded app tree. Only a scope naming a different device is a real negative now; a missing one stays unknown. The shared post-gesture comparison token used comparisonKey or the backend alone, so an app capture and a sheet capture — both XCTest — compared equal and a sheet appearing or dismissing read as a stable surface. The token now carries the surface, which covers stabilization, verify and settle through the one path they share. Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the shared disclosure helper could not report the sheet on either outcome. It is preserved now. Each fix has a regression that fails without it.
|
Addressed at Probe device scope. A successful Generic post-gesture comparison. You were right that this was still open — Mutating find. Size, reconciled against this head. Reproducing CI's own definition locally ( Of that, +8,908 B is this branch's own code measured against the merge-base — Swift +3,122 ( The Correction to my earlier comment: the "~1.9 kB of diffuse minifier churn" I claimed does not exist. That figure was a base mismatch — I itemized against the merge-base and compared it to a CI number measured against the PR base sha. Rebuilding the same sha in two independent directories yields byte-identical Gates: |
|
The probe and mutating-find fixes are addressed at 2200150, and the size accounting now reconciles. The verify/settle gap remains: the new key only protects deferred post-gesture stabilization. |
An eligible iOS simulator snapshot is served by the host AX bridge (packages/platform-apple/src/snapshot-route.ts), which never reaches the runner's prepareActiveCommandContext. The rescue's direct querySelector always does, so the two requests share that surface policy only when the capture is runner-routed too - which is the case #2448 forces for the system surface. Keep the unchanged statement that no surface identity crosses the two requests.
`--verify` compared node digests and `--settle` diffed node-only baselines, so an app baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one presentation: a meaningless changed verdict, and a whole-surface replacement presented as an in-surface diff with refs. The pre-action baseline now travels with the surface its capture described, from the resolution and the session frame through to the settled capture, and one module owns the comparison for both routes. Across a surface change no same-surface claim is made: evidence reports the transition instead of a digest comparison, the settled diff and its refs are withheld, and both payloads disclose the transition.
|
Verify/settle gap addressed at 36fdde3. Identity now travels with the baseline, not beside it. One module, What a cross-surface verify/settle now does
Same-surface behavior is byte-identical: all 55 pre-existing verify/settle tests pass unchanged (two accessor renames in New tests (6, beside the existing verify/settle tests) cover app→sheet and sheet→app through each route: Revert-sensitivity — three separate reverts, each confirmed:
Gates on the pushed commit: |
|
The verify/settle comparison fix looks correct at 36fdde3, including both transition directions and the combined press path. Two validation gaps remain: add an app-to-sheet and sheet-to-app regression through generic scroll/back --settle, where the baseline is passed separately, and exercise the new verify/settle transitions on the live sign-in flow. The earlier live sign-in evidence does not cover these new outputs. The generic-route test should fail if its surface identity is dropped and assert that no cross-surface diff or refs are returned. Please also update the size breakdown for the current +14.8 kB report; the previous accounting covers +10.8 kB. Coverage has now failed on a related test-size guard: settle.test.ts grew from 2,359 to 2,528 lines. Split the new surface tests along their owning module, preserving command-path coverage, rather than raising the limit. Native smoke checks are still running. The readiness hold is for the missing evidence, not CI alone. |
|
Challenging the size of this one too — and I ran the spike rather than proposing a redesign on theory. There is a simpler mechanism that is both smaller and strictly more accurate than what this PR currently ships. The claim. The host-side presence probe ( Why the bridge can answer. What I measured (iOS 26.2 simulator, real
Two findings, one negative and one positive:
So the trade is not "smaller but rougher" — it is smaller, faster and more accurate:
Two things that are genuinely not free, so I'm flagging them rather than hiding them: it needs one new early branch in Separately, on shape. The surface identity has now spread across six comparison sites over four review rounds — tap corroboration, post-gesture stabilisation, find, the disclosure seam, and now verify/settle. Each round found another site that compared across the boundary. That reads less like a missing check and more like a defect that should be made unrepresentable: the comparison primitives take bare I haven't changed anything here — this PR works and is tested, and swapping the mechanism mid-review is your call, not mine. Happy to do it in this PR or as a follow-up that deletes the probe. The spike binary and method are reproducible if you want to see it yourself. |
… mirror The #2438 cross-surface cases were appended to `settle.test.ts`, taking it over the test-file size ratchet (2528 lines, 2359 at the merge-base). They assert the comparison `post-action-surface.ts` owns, so they move to that module's mirror test file, and the device double plus the trees both files drive move to a sibling fixtures module under `__tests__/` rather than being duplicated. Pure move: every test and every assertion is unchanged, and `settle.test.ts` is back under its merge-base length.
|
Fixed the Coverage job's test-file size ratchet in f27156c by taking the split it prescribes — no ratchet or baseline edit. What moved where:
Pure move: the 35 test names and all 155 assertion lines are byte-identical to Gates on the pushed head: |
|
The test split is clean at f27156c: the four transition tests retain their assertions and still exercise the command path. The previously requested generic-route regression, live verify/settle evidence and current size breakdown remain outstanding. The reported AX hit-test spike is useful evidence for a smaller design, especially with a lingering host process. It has not been integrated or validated through the production bridge, so it is a proposal rather than a resolved gap. Please keep that design decision separate from the remaining validation. |
…oute `scroll --settle` and `back --settle` plumb the baseline's surface identity through `baselineSurfaceBundleId`, but nothing asserted it: the generic route had zero coverage of the #2438 refusal, so a regression there would have been silent while the element-targeted route stayed green. Assert the same contract the targeted route guarantees, in both directions and for both commands: no diff is attached across an app/sheet boundary — therefore no tail and no `refsGeneration` — the transition is disclosed, and the settle observation still reports its own verdict alongside that disclosure. Each direction falsifies a different half of the plumbing, so both are needed: dropping the baseline's surface identity fails only the sheet-to-app tests (an app baseline has no surface id to lose), and dropping the settled capture's fails only the app-to-sheet tests. No production change: the plumbing was correct, only untested.
|
Added the outstanding generic-route regression in Routes now covered —
Each asserts the same contract the element-targeted route already guarantees in Revert-sensitivity. Each direction falsifies a different half of the plumbing, which is why both are needed:
So no new test is vacuous, and production files were restored to their pushed state after each probe. No production change. The regression found no gap — the generic route's plumbing was already correct, just untested. Test file only, 365 → 614 lines (tripwire 1,000). Gates on the pushed commit: |
|
The generic-route regressions at 38c1d9f address the coverage gap: scroll and back exercise both surface transitions through the real settle path and assert that no diff or refs escape. No findings in this test-only change. The remaining requirements are live verify/settle transition evidence and an updated size breakdown; the earlier sign-in run does not cover those new outputs. |
|
All three outstanding items are addressed. Keeping the AX hit-test design out of this, as asked — it is filed separately below. Live verify/settle evidenceRun on the shipped path at
The control is the one that matters for regression risk: a same-surface press still produces a real settled diff with refs, so the refusal is scoped to the boundary rather than suppressing normal settle output. Both transition directions produce their own disclosure wording ("is presented" vs "was presented over the app"). Generic-route regressionCovered at The revert probes turned up something worth recording: the two directions falsify different halves of the plumbing. Reverting the baseline surface fails only the two sheet→app tests — app→sheet structurally cannot fail that revert, because an app baseline has no surface id to lose and the change is derived from the settled capture alone. Reverting the settled capture's surface fails only the two app→sheet tests. Removing the refusal fails all four. So neither direction substitutes for the other; that is now documented in the test file header. No production gap was found — the generic route's plumbing was already correct, just untested. Size breakdown at this head
Per commit from Against the merge-base, +12,883 B is this branch and −1,870 B is base drift (main shrank between the merge-base and this PR's base sha, so the diff against a smaller base reads larger by the same amount). 12,883 + 1,870 = 14,753 — residual 0 B. These are CI's own measurements, read from the AX hit-testAgreed it is a proposal, not a resolved gap — it has not been through the production bridge. Filed as its own issue so it does not ride on this PR's validation. |
iosSystemSurfaceDisclosure() only mapped provenance-or-nothing onto the shared constant for one caller, so the caller now reads the constant directly and the wrapper is gone. Its test becomes a test of the transition disclosure, which is the function that still earns its place (the "sheet is gone" sentence). readAppleSnapshotResult also called readSystemSurfaceProvenance twice inside one spread; it is bound to a local and read once.
prepareActiveCommandContext checks for a presented system surface before it resolves or activates command.appBundleId, so a command naming a different app is still served the sheet. That is intended, but the code does not read that way; the amendment now says it plainly.
|
Reviewer cleanups pushed in Done
Not done — item 3 (payload back to Stopped rather than forced, as instructed. The mutation is the local binding in
That is five or six signatures and ~10 call sites to remove one Gates (on the pushed commit): |
…ineage A capture of an in-place system surface (a web sign-in sheet) describes a different presentation than a capture of the app, so it must never compare equal to one. The `present` branch of the iOS snapshot route returned a bare fallback, so that capture carried no comparison identity at all, and two comparison sites hand-rolled the distinction from `iosSystemSurfaceBundleId` instead. The probe now reports which host it matched, and the `present` branch goes through `runFallback` like the `unknown` branch beside it, lineaged to `<device>:<host bundle>`. The comparison key then differs from an app capture's by construction, so the surface branch in `hasMatchingPresentation` and the surface concatenation in `snapshotComparisonKey` are gone: both sites are plain key equality again, and neither knows that system surfaces exist. Two captures of the same surface still share a lineage, so they stay comparable with each other. A presented surface is not a bridge failure, so it gets its own warning wording: the bridge is inapplicable here, not unavailable.
|
Applied the second-lineage-channel simplification ( The cause, fixed at the source. "Anything but a proven Deleted
Deliberately not deleted: One design point in the new branch. No Warning wording. "Simulator AX snapshot unavailable" was wrong here — the bridge is healthy and merely cannot see the surface.
How the guarantee is pinned, and that it is not just assertion-shaped. The chain is two links, and I mutation-checked each:
One honesty note on scope. The guarantee is structural wherever the route attaches an identity, which is every eligible iOS Simulator capture — and every system-surface capture is one by construction. Captures that bypass the route entirely ( Line delta: +163 / −47, net +116. Production code is roughly flat (−9 in the two daemon sites, +25 in the route, +14 for the probe carrying its host); the growth is the regression that pins the new structural property plus the shared key fixture. The win here is one lineage channel instead of two, not line count — say the word if you would rather have the route test trimmed further. Gates (on the pushed commit): One earlier |
…coped value The same pre-action tree travelled as a flattened nodes/surface pair at every boundary, and each boundary rebuilt it with a conditional spread. Carry SurfaceScopedNodes itself instead: - ResolvedInteractionTarget gets preAction?: SurfaceScopedNodes, replacing the preActionNodes/preActionSurfaceBundleId pair and the PreActionBaselineFields intersection on all three arms of the union. - SettleObservationCommandOptions gets baseline: SurfaceScopedNodes, replacing baselineNodes/baselineSurfaceBundleId. - RefResolution carries tree: SurfaceScopedNodes instead of nodes plus a loose surfaceBundleId. That retires preActionBaselineFields(), preActionBaseline(), evidenceBaseline(), the local SettleBaseline type, the split-then-reassemble in settleObservationCommand, and the 'preActionNodes' in resolved narrowing tests. SurfaceScopedNodes moves to contracts, where ResolvedInteractionTarget can name it; only two sites now mint one from a SnapshotState. Behaviour is unchanged: the cross-surface guarantees keep their existing tests.
|
Applied the "one value, five names" simplification in The nodes and their surface now travel as one Type changes
Deleted
Conditional spreads that write The drift-hazard comments you flagged are gone. Each was warning about something the type now states, so they were deleted rather than reworded:
The one remaining "why" lives at the owning type in contracts, which is where a reader needs it. Behaviour unchanged. No test assertion was weakened, rescoped or deleted. The cross-surface regressions (
Out of scope, flagged: Gates (on the pushed commit, rebased onto |
|
The new lineage construction at 6ba5b3d introduces a cross-surface comparison bug. The process probe can stay positive after the sheet is dismissed, while XCTest correctly returns app content. The route still stamps that capture with the host lineage, so it compares equal to the preceding sheet capture and post-gesture polling can miss the transition. Derive the identity from the returned capture’s systemSurface, not the process probe. Add a regression where the probe stays positive while the runner changes from sheet to app. The removed surface check also covered preferredBackend/customActions captures that bypass the route. Those still go through XCTest and can report a system surface, but have no comparison key; they now fall back to legacy presentation matching and can corroborate a tap across app/sheet content. Preserve the distinction at the capture owner for those paths too, with a regression for a route-bypassing capture. The earlier live evidence predates this identity change; repeat the dismissal/lingering-host case after fixing it. |
|
All three simplifications are in at 1. Second lineage channel — removed. Your diagnosis was exact. The probe now reports which host matched, and the This was flat in production lines (−9 at the two daemon sites, +25 route, +14 probe); the win is one channel instead of two, not line count. Coverage was mutation-checked rather than assumed: collapsing One deliberate asymmetry: the Scope limit, stated rather than hidden: the guarantee is structural wherever the route attaches an identity. Captures that bypass the route ( 2. One value, five names — done, −72 lines. Two things found in passing. Seven drift-warning comments were deleted — the ones whose existence was your evidence for the problem. The mint function stayed out of contracts on purpose: moving it would turn a types-only module into a runtime one and shift every importer's eager closure. 3. Thin wrappers — done. 4 and 5. Filed the shared system-surface model as #2489 rather than blocking. Probe left as-is — your Smaller notes. ADR now states the Swift branch precedence, with the ordering verified in source first. On On the red smoke: it is the lane, not this PR. Two runs of this same commit failed with two different assertions — |
|
The latest explanation does not resolve the two comparison concerns at 6ba5b3d. A positive process probe can outlive the sheet, so its host identity cannot stand in for the surface the runner actually returned. The route-bypassing captures also need that returned-surface distinction. Please derive comparison identity at the capture owner and cover dismissal with a lingering host plus a pinned-backend capture; then repeat the live transition run. The current smoke failure is RUNNER_BUSY at the WebView page wait, matching the separately reported main failure, and appears unrelated to these findings. |
The `present` path stamped the capture's comparison lineage from the host-side presence probe. That probe answers about a host PROCESS and deliberately stays positive while a dismissed host lingers, so during that window the runner truthfully returned APP content while the route lineaged it to the HOST: the sheet capture before the dismissal and the app capture after it compared equal, and a post-gesture poll could read the transition as a stable surface. Derive the identity from the returned capture's `systemSurface` instead - the runner stamps the surface it actually served - and say which of the two the capture holds in the warning. The probe's host is now evidence only: it names the matched host in a route diagnostic so a lingering window is legible in the daemon log. Other reasons keep their lineage and wording byte for byte. Captures that bypass the route's planning (a pinned backend, a custom-actions read) also reach the runner, and the runner serves the sheet there too. They carried no comparison key at all, so a sheet and app content fell through to legacy presentation matching as one presentation and could corroborate a tap across the two. The capture owner now gives those a surface-scoped identity as well, with no fallback-source residue: nothing fell back. An app capture off the route is untouched.
|
Both points taken; fixed in 28c6e86. What the identity is derived from now. The The design point from the previous commit is unchanged: the How the bypass paths are covered. Revert-sensitivity.
One existing test changed, assertions untouched: Not changed, flagged rather than silently left: the bridge-failure reasons ( Gates on the pushed commit: |
|
Both findings were correct, and the second one corrected a claim of mine that was simply wrong. Fixed at Where the identity comes from now. On route-bypassing captures — you were right and I was wrong. I had written that those paths "never report a surface, since the bridge serves the occluded app tree". That conflated bypassing the route with using the bridge. They go to the runner, which serves the sheet and stamps it. The Live evidence, re-run on
That second row is the bug you found: previously the probe stayed positive and the route stamped that app capture with the host lineage. It now follows the capture. Revert-sensitivity, both proven: reverting fix 1 makes No hand-rolled surface branch was reintroduced in One residual I am flagging rather than fixing, because it is outside what you asked and I did not want to widen the diff unasked: the bridge-failure reasons ( |
|
The lingering-host and pinned-backend fixes at 28c6e86 address the two reviewed cases, and the new live dismissal run covers the requested transition. One instance of the same comparison bug remains in runFallback (packages/platform-apple/src/snapshot-route.ts:278): it stamps the supplied app lineage even when the runner returns systemSurface. With the bridge circuit already disabled, capture app content, then let the sheet appear after the next absent probe but before the runner capture. Both captures receive the same app-generation key, so tap corroboration can treat different surfaces as comparable. Derive the served-surface identity for every runner fallback at the shared stamping point, preserving fallback reasons and generation evidence where applicable, and add the absent-probe/circuit-disabled regression. This is needed before approval; iOS smoke is also still running. |
A runner fallback's comparison identity was decided per call site. The `present` path and the off-route path read the runner's `systemSurface` stamp, but the plain `runFallback` path did not: it stamped the app lineage the route had planned, whatever the runner returned. The probe and the capture are separate observations, so a sheet can appear in the gap between them. With the bridge circuit already disabled for the generation, an app capture and a later sheet capture both received the same app-generation key, so tap corroboration could treat two different surfaces as comparable. `stampFallback` now owns the decision for every runner fallback: the surface the runner served outranks the app lineage the route planned. The reason the bridge was skipped survives either way, and app-generation evidence leaves with the app lineage it describes, so two captures of the same sheet still compare equal. `runSurfaceFallback` keeps only the reason, which is the one thing that path decides.
|
Fixed at What changed. const served = result.systemSurface;
comparisonIdentity: runnerComparisonIdentity(
served ? surfaceLineage(deviceId, served.bundleId) : lineage,
request,
[...(served ? [] : residue), { kind: 'fallback-source', producer: 'apple-runner' }],
),Every arm that reaches the runner goes through it: Preserving the two things you named. Fallback reasons. The reason is independent of what the runner found, so it survives untouched. But the generic warning sentence would then have claimed an app generation the capture does not hold, so there is one new branch: a served surface on a non-surface reason reads Generation evidence. App-generation evidence leaves with the app lineage it describes. A surface is not an app generation, and the arm's per-capture The regression you asked for, plus its sibling:
Seeded. Reverting only the Size. +19 production code lines and +17 comment lines in
One thing stated plainly: this round is unit-proven, not live-proven. The window is a race between the presence probe and the runner capture, which I cannot stage deterministically on a simulator — the unit test is the instrument that can hold it. The live runs on Separately, the iOS smoke red on this PR is not this PR: it is the |
|
Smoke on Same signature as before and as on This PR's feature never ran in that job. The only occurrence of The wedge is in the harness, and it is in this tree because it is in Why no fix here. #2493 already fixes exactly this, with live before/after numbers, and it changes the same harness function plus the
|
|
No remaining code findings at 82b623d. The shared fallback now derives identity from the returned surface on every path; the circuit-disabled regression covers the race, and the earlier live sheet/dismissal evidence remains applicable. Ready for human review. The failing iOS smoke still reports the known WebView RUNNER_BUSY harness issue and appears unrelated; land #2493 and revalidate this branch before merge. |
Summary
iOS apps that sign in via
ASWebAuthenticationSessionpresent the identity provider incom.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AXprimaryApp.This serves and drives the sheet in place:
contracts/fixtures/ios-system-surface-hosts.json, mirrored by the TypeScript and Swift registries under a parity test).psprobe and takes the runner path; the bridge would serve the occluded app tree as if healthy.open com.apple.SafariViewServiceis refused (UNSUPPORTED_OPERATION); captures carry a system-surface disclosure.Presence is foreground state, not tree content — a torn-down host serves a richer tree than a live one — and the never-activate guard is what keeps that predicate sound, which also makes issue #2438's stale-tree bug unrepresentable for this flow. Design rationale in the ADR 0004 amendment.
17 files, Apple platform + contracts + docs only. Closes #2438.
Validation
Tested at
0cde7a4d2e.pnpm check:affected --runpasses (40/40; lint, typecheck, layering, fallow, unit). Swift: runner builds + 2 registry parity tests pass.Live on iPhone 17 / iOS 26.2 (real daemon + runner, an
ASWebAuthenticationSessionprobe app + local IdP):open svsrefused with the sheet intact;wait label="LOGIN"→press role=textfield label="Email"+type→fill role=securetextfield(values confirmed) →press role=button label="LOGIN"fired the auth callback and returned to the app; post-dismiss captures revert to the bridge with no disclosure.Follow-ups: #2449 (test-app fixture + CI lane), #2450 (verify/settle lineage guard), #2451 (selector-route disclosure parity), #2452 (ADR 0011 offscreen comment).
Caveat: the first snapshot after a cold runner can return 0 nodes (pre-existing mid-bundle flake); use
wait stable/wait <element>.