Skip to content

fix(ios-runner): keep the presented surface's route for no-app commands - #2954

Open
thymikee wants to merge 8 commits into
mainfrom
fix/runner-launch-presented-surface
Open

thymikee wants to merge 8 commits into
mainfrom
fix/runner-launch-presented-surface

Conversation

@thymikee

@thymikee thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #2899. Carries the accepted findings from its bot rounds.

  1. Regression — .noApp dropped the presentedSystemSurfaceHost() route the merge-base consulted first, resolving a target from the request instead of the standing cache. The arm serves the presented host in place again, else the standing target (iOS: ASWebAuthenticationSession cannot be automated — binding the session to com.apple.SafariViewService destroys the view, and the tree served afterwards is stale #2438). Both facts are preparation-level and no .noApp response body reads either; the arm says so in one sentence pointing at Command.traits as the member list.
  2. Split case .existingApp, .mayLaunch: — the coordinate-only tap bypass was dead under .existingApp. No behavior change.
  3. Fixed the alert-fixture query column comment; documented the .existingApp refusal as iOS-enforced.
  4. The systemSurfaceHostState test seam answers every registered host while set.

Rejected: deriving witness clearing from convertsRecordedFailure newly clears no querySelector witness — the merge-base predicate cleared it too; the table test pins clearing per command.

Validation

Head 511dbb4; rebased onto origin/main f4fccd02 (#2911). Conflict shape: #2911 moved target identity into @MainActor RunnerMainOwnedState and annotated the test overrides, so the seam sits beside blockingSystemModalPresenceOverrideForTesting in the same #if AGENT_DEVICE_RUNNER_UNIT_TESTS block, is read only through systemSurfaceHostState, and the test is @MainActor writing mainOwned.app/.bundleId — no parallel channel, no currentApp left under apple/; the probe's two callers are both @MainActor.

check:affected --run, check:xctest-selection, check:packaged-runner-swift, build:xcuitest:ios (clean DerivedData, so the isolation scan covered every file) and build:xcuitest:macos pass. Simulator XCTests on rnav-repro: 26 tests across the touched dispatch/models/alert-dispatch files, 0 failures; reverting the .noApp arm to main's fails 4 assertions, restoring returns green.

Pre-#2899 evidence (64854af:466-469): the chain probed the presented host first and no body consumed it, so the route is unobservable live; an earlier head proved the sibling .existingApp path live on rnav-repro (is exists → APP_NOT_RUNNING).

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.85 MB 4.83 MB -12.9 kB
Package (unpacked) 4.85 MB 4.83 MB -12.9 kB
Package (download) 1.45 MB 1.45 MB -3.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.9 ms 27.2 ms +0.3 ms
CLI --help 79.1 ms 79.2 ms +0.0 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 5709698.

The restored .noApp arm returns ActiveCommandContext(app: presented.app, systemSurface: host), but no .noApp command reads either value: executeOnMain drops context.systemSurface (https://github.com/callstack/agent-device/blob/5709698/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift#L348), and status, uptime, appState, terminate, targetReset, shutdown, and recordStop never read activeApp. iOS screenshot resolves its own target through resolveAppWithoutActivation(command:) and puts no systemSurface in its payload, and macOS screenshot never reads the prepared app. Snapshot, the only command that stamps provenance, is .existingApp and already checks for a presented surface in prepareActivatedTarget on main. So the #2438 provenance loss this arm targets does not happen on main, and the branch at https://github.com/callstack/agent-device/blob/5709698/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift#L470 fixes nothing observable, while adding two XCUIApplication.state IPC queries (SafariViewService, PassbookUIService) to every iOS status/uptime/appState/shutdown/terminate/recordStop/targetReset/screenshot call. The new test asserts a preparation value nothing downstream reads, and its comment claims the snapshot/querySelector route consumes this preparation, which isn't right since both are .existingApp. The CommandLaunchPolicy.noApp doc promises in-place serving that no command's output reflects, so a future reader will trust a provenance route that doesn't exist. A .noApp preparation has no reader, so it should not probe or claim any surface: enumerate the readers through the .noApp rows of CommandType.traits and check each executeOnMain/executeOnMainPrepared arm, then either drop the presented-surface branch, the override seam, and the test, and return currentApp ?? app (or skip preparation for .noApp entirely), or, if screenshot should carry #2438 provenance, make it the reader by having iOS screenshot consume the prepared context and stamp systemSurface in its payload, tested through executeOnMain's response.

Not blocking: presentedSystemSurfaceForegroundOverrideForTesting only forces listed members to foreground, so non-members still read live state and the second-host subtest depends on the first host not being live-foreground (https://github.com/callstack/agent-device/blob/5709698/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift#L603) — worth reporting non-members as notRunning when the override is set, but moot if the seam above gets deleted, so take it or leave it.

Since nothing reads a .noApp preparation, isn't the smallest correct change to not prepare .noApp commands at all — answer them in executeOnMain before prepareActiveCommandContext, or return .context(ActiveCommandContext(app: currentApp ?? app)) without a probe, and delete presentedSystemSurfaceHost's test seam and the new dispatch test with it? The arm split, the iOS-only doc, the TS comment, and the witness-clearing table pin look sound and can stay. Doesn't this depend on deciding whether screenshot owes #2438 systemSurface provenance first — because only once the screenshot payload gains that field and a consumer does a .noApp presented-surface probe have anywhere to go?

19 checks are green with 0 failing, and the runner Swift paths this diff touches are covered by the xcuitest build lanes that passed. I did not run a live check against a presented SafariViewService or PassbookUIService session; the analysis above shows no .noApp response could reflect it anyway, so this is a static read of 5709698, not a device observation. I did not measure the latency of the two added XCUIApplication.state probes on status/uptime/shutdown, though the pre-#2899 runner paid the same cost. I did not run the author's Simulator XCTests or mutation runs, and the author's live rnav-repro run (is exists → APP_NOT_RUNNING, press, screenshot) is unverified beyond the PR body, so those claims come from the description, not from my own run.

Before this can merge, either the .noApp presented-surface branch, its test seam, and its test need to come out, or iOS screenshot needs to become the reader that stamps systemSurface and gets tested through the executeOnMain response.

@thymikee

Copy link
Copy Markdown
Member Author

The code looks right at e8fcd97. The .noApp preparation now matches the pre-#2899 chain for every member, and no .noApp response body reads the prepared app or systemSurface, so the SafariViewService/PassbookUIService route this fixes cannot be observed from a live run anyway. All 19 checks pass, including the xcuitest iOS and macOS build lanes that compile the changed runner Swift, and I see no conflicts.

I did not run the Simulator XCTests or any mutation pass; the claim that the test fails without the fix rests on reading the merge-base arm (64854af:466-469), not on a run. I did not measure the latency of the two XCUIApplication.state probes on status, uptime, and shutdown; they match the pre-#2899 cost by inspection. Nothing here blocks merge.

Not blocking: could the new 12-line comment on the .noApp arm in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift around line 467 be one sentence pointing to traits(for:) as the member list instead of a prose copy of the .noApp rows that will drift as members are added?

@thymikee thymikee added ready-for-human Valid work that needs human implementation, judgment, or maintainer merge and removed ready-for-human Valid work that needs human implementation, judgment, or maintainer merge labels Sep 25, 2026
@thymikee

Copy link
Copy Markdown
Member Author

#2911 merged after my review of e8fcd97, and this branch now conflicts with main. Please rebase onto main. The code verdict above still holds for e8fcd97; I will check the rebase for the conflict resolution only.

`prepareActiveCommandContext` consulted `presentedSystemSurfaceHost()` before the lifecycle
branch, so a `screenshot`, `status`, or `uptime` taken while a registered system surface was
foregrounded was served that host in place with its provenance (#2438). The launch policy axis
added in #2899 answered `.noApp` by resolving a target from the request, dropping both the
presented route and the standing cached target the merge-base fell through to. Neither answer
brings an app forward, so only the target and its disclosure differ; on a foldable the first is
the panel that is lit.

Restored with a regression test over a forced-presented host, and a second half that names a
bundle the session never bound — the only request shape that tells the standing target apart
from one resolved from the request.
…on the table

The combined `case .existingApp, .mayLaunch:` arm asked every read whether it could skip
preflight. That bypass requires a coordinate-only `.tap` with a cached foreground target, so it
was unreachable for the `.existingApp` half and read as if a read could skip preflight. Split so
the check lives only under `.mayLaunch`; those commands never satisfied it before either, so
behavior is unchanged.
…type

The consumer derived it inline from `convertsRecordedFailure` and the owner set, so the rule was
a predicate inside one function that no table could pin. `Command` now answers it once — still
derived rather than declared as a fifth fact the retired axis was — and `executeOnMainPrepared`
reads that. The truth table is unchanged for every command.
`notRunningRefusal` is `#if os(iOS)`, so off iOS an `.existingApp` command reaches activation
exactly as it did before this axis existed and no refusal can occur there. `existingApp` promised
a refusal with no platform stated, and `selectorResolution` repeated it, which is how #2890's own
`querySelector` fix reads as a macOS/tvOS guarantee it never was. Enlarging the refusal to those
platforms would change behavior this issue never touched, so the scope is stated instead.
…anything

The column is true only for `get`; `accept` and `dismiss` are false because they mutate, so the
comment naming the shared fact as "the alert request changes nothing" contradicted two of the four
rows it then reads. Phrased to match the Swift side, which already names `alert get` as the one
alert action that may be replayed.
Whether the prepared path drops a remembered text-entry tap was argued from the retired read-only
fact rather than asserted. The table now carries that column for every command, compared against
the merge-base predicate plus a literal copy of its read-only set, so neither replay eligibility nor
witness clearing can move with the classification under test.

The commands `executeOnMain` answers before `executeOnMainPrepared` runs are compared nowhere: the
old predicate never evaluated for them and neither does the derived one. `snapshot` stays in scope
because it reaches that body, and `appState` postdates the merge-base and owes it no equality.
Adding the `querySelector` bundle id to the witness-owner set — the change the review asked for —
turns that row red.
…gistered host

The override for `systemSurfaceHostState` only forced the hosts a test named, so
every other registered host still answered from live simulator state: a registry
walk could not be pinned end to end, and a new registry entry made the test's
answer depend on the machine again. The override is now total while set —
members report foreground, non-members do not. Production is unaffected: the
seam is compiled only under the unit-test flag.

This is hardening, not a mutation-proofed fix. Mutating the override back to the
member-only form stays green on a simulator, because the two registered hosts
already report notRunning there — which is the very reason the seam exists.

Also write down what the `.noApp` presented arm actually controls. Tracing the
first parent of c7b79ee: no `.noApp` command read the prepared target or the
disclosed surface even pre-split (`status`/`uptime`/`recordStop`/`terminate`/
`targetReset`/`shutdown` answer before reading a target, iOS `screenshot`
re-resolves its capture display (#2728), macOS `screenshot` resolves the named
app, and `snapshot` — the only provenance consumer — is `.existingApp` and keeps
its route in `prepareActivatedTarget`). The arm restores the prepared subject and
disclosure, which is what the merge-base chain gave these commands; the test says
so instead of claiming a payload the runner never stamped.
@thymikee
thymikee force-pushed the fix/runner-launch-presented-surface branch from e8fcd97 to 511dbb4 Compare September 25, 2026 11:12
@thymikee

thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member Author

Rebased onto origin/main f4fccd02; new head 511dbb4.

Conflict resolution: #2911 moved target identity into @MainActor RunnerMainOwnedState, deleted currentApp/currentBundleId, and annotated the #if AGENT_DEVICE_RUNNER_UNIT_TESTS override vars. The new seam conforms rather than running a parallel channel: presentedSystemSurfaceForegroundOverrideForTesting is declared beside blockingSystemModalPresenceOverrideForTesting in that block in RunnerTests.swift, its only read is systemSurfaceHostState, and the test is @MainActor and drives mainOwned.app/mainOwned.bundleId. The .noApp arm reads the standing target as mainOwned.app ?? app. The read path stays main-only: both probe callers, prepareActiveCommandContext and prepareActivatedTarget, carry @MainActor added by #2911. build:xcuitest:ios with a clean DerivedData passes the isolation scan over every file.

Non-blocking nit applied in 511dbb4: the 12-line .noApp comment is now two sentences naming what the arm does and pointing at Command.traits as the member list. The per-command answer-source walk is gone rather than relocated — CommandLaunchPolicy.noApp keeps the one non-obvious half (preparation-scoped, no consumer reads either prepared fact) and the pre-#2899 evidence lives in this body. The test's doc comment lost the same prose copy and now states what the test pins and its two mutations.

Verification on this head: check:affected --run, check:xctest-selection, check:packaged-runner-swift, build:xcuitest:ios/:macos pass; 26 XCTests on rnav-repro (touched dispatch/models/alert-dispatch files, including RunnerTests+AlertDispatchTests under these changes) pass; mutation re-checked here — reverting the arm to main's fails 4 assertions, restoring returns green.

@thymikee

Copy link
Copy Markdown
Member Author

The rebase at 511dbb4 resolves the conflict with #2911 correctly. The .noApp arm still uses the presented system surface first and then the standing target, and the test seam sits in the same unit-test block as the other main-actor overrides. The earlier code verdict holds, and the shorter .noApp comment is in.

I did not compile the Swift unit tests at this head. The main-actor call to systemSurfaceHostState follows the pattern already on main.

Smoke Tests is still running. It runs this .noApp path (screenshot and status), so a red result there needs a look before merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant