Skip to content

Commit 6c0fcb6

Browse files
thymikeeclaude
andauthored
fix: reject distinct ambiguous mutation targets (#1667)
* fix: reject distinct ambiguous mutation targets * fix(ios): scope the raw-match rejection to mutating dispatches `RunnerTests+Interaction.findElement` applied the new fail-closed classification to `querySelector` as well as press/type, because the read call site takes the default `allowNonHittableFallback: false`. With one visible/hittable match and one non-hittable same-selector duplicate the query started returning AMBIGUOUS_MATCH where it previously selected the hittable element, and `queryDirectIosSelectorOrFallback` preserves that error for read callers — so `get`, `is`, and `wait` surfaced an error instead of their prior answer. `classifyDirectSelectorCandidates` now takes a `rawMatchPolicy`. Mutations keep `.rejectDistinctMatches` (the default, so no mutation call site changes); `queryElement` passes `.preferHittableMatch`, restoring the prior read rule: prefer the single hittable match, ambiguous only when hittable matches compete, and never adopt the Maestro coordinate fallback. The Maestro expected-point path is untouched. Covers the one-hittable + one-non-hittable read, competing hittable reads, and the non-hittable-only read. ADR 0011's amendment now states the scope. * test(ios): execute selector read ambiguity regression --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 919700f commit 6c0fcb6

43 files changed

Lines changed: 1030 additions & 241 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ios.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ jobs:
9797
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand \
9898
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedTextCommitProgressWalksExpectedPrefixOnly \
9999
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \
100+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testQuerySelectorPrefersHittableMatchOverNonHittableDuplicate \
100101
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication \
101102
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testMissingBundleCommandInvalidatesCompleteCachedTargetState \
102103
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- `agent-device help workflow` is now a compact ~8KB card instead of a ~41KB dump; the same depth still exists, split into `help scripting` (save-script, secret-safe fills, batch JSON, replay divergence/repair, recording) and `help gestures` (multi-touch shapes and platform quirks), plus a few paragraphs folded into the topics that already owned the subject (`help debugging`, `help physical-device`, `help validate`). Every `help <topic>` first line is now `agent-device <version> — <topic>` so an agent can read the installed version from its mandatory first help read instead of a separate `agent-device --version` call.
66
- Cloud iOS (BrowserStack, AWS Device Farm): `snapshot` and `diff` no longer fail with `SESSION_NOT_FOUND` on a live provider session (#1658). The app-session guard they ran belongs to the local XCUITest runner, which must attach to a target app; a cloud capture reads the provider's own driver session and needs no app identity, so the guard now applies to local Apple targets only. Relatedly, a cloud iOS `open com.example.app` now records that bundle id on the session — the provider path skips local app resolution (no simctl/devicectl reaches a hosted device), and used to drop an explicitly spelled bundle id along with it, leaving the session with no app identity at all. Opening a second bundle id replaces the first, matching the local path, where an explicitly spelled target always wins over the session's current app; deep links, display names, and bare `open` still keep the app already tracked.
77
- Cloud `fill` (BrowserStack, AWS Device Farm) now witnesses that the field it tapped actually holds text-entry focus before sending its keys, instead of dispatching tap and keys in back-to-back requests (#1658). A WebView input — an OAuth/SSO page in a Safari view controller, for example — takes first responder asynchronously, so the keys used to land with nothing focused while `fill` still answered "Filled N chars"; tapping and filling as two separate commands worked only because the round trip between them gave the field time to focus. The witness is the focused element's own geometry: `fill` polls the active element and proceeds only once it contains the point it tapped, which is the one signal that identifies *which* field took focus. Keyboard visibility cannot — it reads the same before and after a second fill into an already-open form, so it could not tell a focused password field from the email field the previous fill left focused. The response discloses `textEntryReadiness`: `focused-element`, or `keyboard-shown` when the driver has no active-element route but the keyboard rose from hidden after the tap. Both describe a fill that witnessed focus before typing; there is deliberately no value for typing without evidence, because nothing renders this field and such a value would reach a caller as an ordinary success. Breaking: when focus cannot be witnessed, cloud `fill` now FAILS with `COMMAND_FAILED` / `text_entry_focus_not_observed` and sends no keys, instead of typing into whatever holds first responder and answering "Filled N chars" — a fill with no witness must not read as a filled field. That covers a tap that focused nothing, a keyboard already up on a driver that cannot name the focused field, and a driver that reports neither (`text_entry_focus_unobservable`, which points at `press` + `type` as the deliberate way to enter text unwitnessed). Only a positively classified unimplemented route counts as unsupported, so a dead session, an auth rejection, or a grid outage surfaces instead of degrading into a blind text entry.
8+
- Changed mutating selector ambiguity semantics (press/click/fill/longpress): duplicate accessibility wrappers collapse only when every match forms one ancestor-descendant chain resolving to the same actionable node. Matches in distinct subtrees now fail fast with `AMBIGUOUS_MATCH` and a bounded, immediately reusable candidate-ref frame; visible/depth/area geometry no longer silently picks a mutation target. The direct iOS XCTest path now counts raw exact matches before hittability and delegates ambiguity to the same runtime rule. AppControlBench provenance: element-14 ran on 0.20.5; this change is intended for 0.20.7+, and comparative benchmark reports should note that it can replace a wrong-success recovery loop with one candidate-pick turn while occasionally adding that turn for genuinely distinct duplicates.
89
- `scroll` and `back` now accept `--settle` (with `--settle-quiet` and `--timeout`), collapsing scroll-then-observe and back-then-observe into one call (#1638). The response carries the same settled payload the touch commands return — verdict, changed-lines diff with fresh refs on added lines, the unchanged-interactive tail, and `refsGeneration` when the settled tree was stored — and is best-effort: it never fails the action. One difference is deliberate: `scroll`/`back` resolve no element, so the diff baseline is the session's stored pre-action tree ("the last tree you observed") rather than a freshly resolved pre-action capture. Both commands now also preserve the daemon on timeout, like the other settle-capable commands.
910
- Security: repository `./agent-device.json` now accepts only project-safe automation defaults. It rejects daemon endpoint/auth/transport/server settings, tenant/run/lease selectors, provider/cloud and Metro connection fields, headers, executable reporter modules, local write destinations, and other operator-controlled values before local module loading or any daemon health/RPC request. Put remote endpoint and token together in protected CI environment variables, user config, an explicit `--config` file, or the existing `connect`/`--remote-config` workflow. Daemon auth tokens no longer travel in serialized command flags.
1011
- `viewport` is now rejected during capability admission on Apple targets instead of reaching the device and failing inside dispatch. No Apple backend can resize a screen — simulator and device geometry is fixed by the selected device type — so `viewport` on iOS/iPadOS/tvOS/macOS now fails with `UNSUPPORTED_OPERATION`, `viewport is not supported on this device`, and a hint pointing at `--platform web` and at picking a different simulator. `capabilities` no longer advertises `viewport` on Apple targets. Web viewport resizing (`agent-device viewport 1280 900 --platform web`) is unchanged, and Android was already denied.

apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,26 @@ - (void)viewDidLoad {
105105
[textField.heightAnchor constraintEqualToConstant:44],
106106
]];
107107
}
108+
109+
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-selector-read-regression"]) {
110+
NSString *const duplicateIdentifier = @"agent-device-selector-read-duplicate";
111+
112+
UIButton *visibleButton = [UIButton buttonWithType:UIButtonTypeSystem];
113+
visibleButton.accessibilityIdentifier = duplicateIdentifier;
114+
[visibleButton setTitle:@"Readable target" forState:UIControlStateNormal];
115+
visibleButton.translatesAutoresizingMaskIntoConstraints = NO;
116+
[self.view addSubview:visibleButton];
117+
118+
UILabel *offscreenLabel = [[UILabel alloc] initWithFrame:CGRectMake(-200, -200, 100, 40)];
119+
offscreenLabel.accessibilityIdentifier = duplicateIdentifier;
120+
offscreenLabel.text = @"Decorative duplicate";
121+
[self.view addSubview:offscreenLabel];
122+
123+
[NSLayoutConstraint activateConstraints:@[
124+
[visibleButton.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
125+
[visibleButton.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24],
126+
]];
127+
}
108128
#endif
109129
}
110130

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
struct SelectorCandidateFacts {
2+
let isHittable: Bool
3+
let hasTappableFrame: Bool
4+
let containsExpectedPoint: Bool
5+
6+
init(
7+
isHittable: Bool,
8+
hasTappableFrame: Bool,
9+
containsExpectedPoint: Bool = true
10+
) {
11+
self.isHittable = isHittable
12+
self.hasTappableFrame = hasTappableFrame
13+
self.containsExpectedPoint = containsExpectedPoint
14+
}
15+
}
16+
17+
enum DirectSelectorCandidateDecision: Equatable {
18+
case noMatch
19+
case selected(index: Int, usedNonHittableFallback: Bool)
20+
case ambiguous
21+
}
22+
23+
/// How many raw exact matches a dispatch may discard before hittability is
24+
/// allowed to pick a winner. The two rows differ because the cost of guessing
25+
/// wrong differs, not because the matching differs.
26+
enum DirectSelectorRawMatchPolicy: Equatable {
27+
/// Mutations fail closed: every raw exact match counts, so a hittable
28+
/// element can never silently win over a same-selector duplicate the caller
29+
/// never saw and act on the wrong one.
30+
case rejectDistinctMatches
31+
/// Reads prefer the single hittable match and ignore non-hittable
32+
/// same-selector duplicates. A read has no side effect to guard, and
33+
/// `querySelector` backs `get`/`is`/`wait` — failing those closed turns a
34+
/// decorative duplicate into an error where the reader previously got its
35+
/// answer.
36+
case preferHittableMatch
37+
}
38+
39+
/// Normal direct selector mutations count every raw exact match before
40+
/// hittability can choose a winner. Reads keep the hittable-preference rule,
41+
/// and Maestro's explicitly requested coordinate fallback keeps its
42+
/// point-filtered compatibility behavior.
43+
func classifyDirectSelectorCandidates(
44+
_ candidates: [SelectorCandidateFacts],
45+
allowNonHittableFallback: Bool,
46+
filtersByExpectedPoint: Bool = false,
47+
rawMatchPolicy: DirectSelectorRawMatchPolicy = .rejectDistinctMatches
48+
) -> DirectSelectorCandidateDecision {
49+
let eligible = candidates.indices.filter { index in
50+
!filtersByExpectedPoint || candidates[index].containsExpectedPoint
51+
}
52+
53+
if !allowNonHittableFallback && rawMatchPolicy == .rejectDistinctMatches {
54+
guard eligible.count <= 1 else { return .ambiguous }
55+
guard let index = eligible.first, candidates[index].isHittable else { return .noMatch }
56+
return .selected(index: index, usedNonHittableFallback: false)
57+
}
58+
59+
var hittableIndex: Int?
60+
var fallbackIndex: Int?
61+
for index in eligible {
62+
let candidate = candidates[index]
63+
if candidate.isHittable {
64+
guard hittableIndex == nil else { return .ambiguous }
65+
hittableIndex = index
66+
} else if allowNonHittableFallback && candidate.hasTappableFrame {
67+
guard fallbackIndex == nil else { return .ambiguous }
68+
fallbackIndex = index
69+
}
70+
}
71+
if let hittableIndex {
72+
return .selected(index: hittableIndex, usedNonHittableFallback: false)
73+
}
74+
if let fallbackIndex {
75+
return .selected(index: fallbackIndex, usedNonHittableFallback: true)
76+
}
77+
return .noMatch
78+
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1653,7 +1653,7 @@ extension RunnerTests {
16531653
return .context(ActiveCommandContext(app: activeApp))
16541654
}
16551655

1656-
private func executeOnMainPrepared(
1656+
func executeOnMainPrepared(
16571657
command: Command,
16581658
activeApp: XCUIApplication,
16591659
alertDeadline: Date? = nil

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ extension RunnerTests {
129129
selectorKey: String,
130130
selectorValue: String,
131131
allowNonHittableFallback: Bool = false,
132-
expectedPoint: CGPoint? = nil
132+
expectedPoint: CGPoint? = nil,
133+
rawMatchPolicy: DirectSelectorRawMatchPolicy = .rejectDistinctMatches
133134
) -> SelectorElementMatch {
134135
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
135136
guard !value.isEmpty else {
@@ -149,35 +150,32 @@ extension RunnerTests {
149150
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
150151
}
151152

152-
var matchedElement: XCUIElement?
153-
var nonHittableElement: XCUIElement?
154153
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
155-
for element in matches where element.exists {
156-
if let expectedPoint, !element.frame.contains(expectedPoint) {
157-
continue
158-
}
159-
if !element.isHittable {
160-
if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
161-
guard nonHittableElement == nil else {
162-
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
163-
}
164-
nonHittableElement = element
165-
}
166-
continue
167-
}
168-
guard matchedElement == nil else {
169-
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
170-
}
171-
matchedElement = element
154+
.filter(\.exists)
155+
let facts = matches.map { element in
156+
SelectorCandidateFacts(
157+
isHittable: element.isHittable,
158+
hasTappableFrame: hasTappableFrame(app: app, element: element),
159+
containsExpectedPoint: expectedPoint.map(element.frame.contains) ?? true
160+
)
172161
}
173-
if let matchedElement {
174-
return SelectorElementMatch(element: matchedElement, isAmbiguous: false, usedNonHittableFallback: false)
162+
switch classifyDirectSelectorCandidates(
163+
facts,
164+
allowNonHittableFallback: allowNonHittableFallback,
165+
filtersByExpectedPoint: expectedPoint != nil,
166+
rawMatchPolicy: rawMatchPolicy
167+
) {
168+
case .noMatch:
169+
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
170+
case .ambiguous:
171+
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
172+
case let .selected(index, usedNonHittableFallback):
173+
return SelectorElementMatch(
174+
element: matches[index],
175+
isAmbiguous: false,
176+
usedNonHittableFallback: usedNonHittableFallback
177+
)
175178
}
176-
return SelectorElementMatch(
177-
element: nonHittableElement,
178-
isAmbiguous: false,
179-
usedNonHittableFallback: nonHittableElement != nil
180-
)
181179
}
182180

183181
// Maestro-compat gate for the non-hittable coordinate fallback: an element
@@ -209,7 +207,17 @@ extension RunnerTests {
209207
}
210208

211209
func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
212-
let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
210+
// querySelector is a read — it backs get/is/wait and the offscreen-refusal
211+
// double-check, none of which mutate. The fail-closed raw-match rule exists
212+
// to stop a mutation acting on an unseen duplicate; applying it here would
213+
// instead turn a decorative non-hittable duplicate into an AMBIGUOUS_MATCH
214+
// for readers that previously resolved the hittable element.
215+
let match = findElement(
216+
app: app,
217+
selectorKey: selectorKey,
218+
selectorValue: selectorValue,
219+
rawMatchPolicy: .preferHittableMatch
220+
)
213221
if match.isAmbiguous {
214222
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
215223
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
5+
func testDirectSelectorRejectsTwoRawMatchesBeforeHittabilityPreference() {
6+
let decision = classifyDirectSelectorCandidates(
7+
[
8+
SelectorCandidateFacts(isHittable: true, hasTappableFrame: true),
9+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true),
10+
],
11+
allowNonHittableFallback: false
12+
)
13+
14+
XCTAssertEqual(decision, .ambiguous)
15+
}
16+
17+
func testDirectSelectorAcceptsOneRawHittableMatch() {
18+
XCTAssertEqual(
19+
classifyDirectSelectorCandidates(
20+
[SelectorCandidateFacts(isHittable: true, hasTappableFrame: true)],
21+
allowNonHittableFallback: false
22+
),
23+
.selected(index: 0, usedNonHittableFallback: false)
24+
)
25+
}
26+
27+
// The read rows below are the regression guard for scoping the fail-closed
28+
// rule to mutations: querySelector backs get/is/wait, so the exact shape
29+
// that must stay resolvable is one hittable match beside a non-hittable
30+
// same-selector duplicate.
31+
func testReadSelectorPrefersTheHittableMatchOverANonHittableDuplicate() {
32+
let decision = classifyDirectSelectorCandidates(
33+
[
34+
SelectorCandidateFacts(isHittable: true, hasTappableFrame: true),
35+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true),
36+
],
37+
allowNonHittableFallback: false,
38+
rawMatchPolicy: .preferHittableMatch
39+
)
40+
41+
XCTAssertEqual(decision, .selected(index: 0, usedNonHittableFallback: false))
42+
}
43+
44+
func testReadSelectorStillRejectsTwoHittableMatches() {
45+
XCTAssertEqual(
46+
classifyDirectSelectorCandidates(
47+
[
48+
SelectorCandidateFacts(isHittable: true, hasTappableFrame: true),
49+
SelectorCandidateFacts(isHittable: true, hasTappableFrame: true),
50+
],
51+
allowNonHittableFallback: false,
52+
rawMatchPolicy: .preferHittableMatch
53+
),
54+
.ambiguous
55+
)
56+
}
57+
58+
// A read never coordinate-taps, so a non-hittable-only match stays a miss
59+
// rather than borrowing the Maestro fallback.
60+
func testReadSelectorDoesNotAdoptTheNonHittableCoordinateFallback() {
61+
XCTAssertEqual(
62+
classifyDirectSelectorCandidates(
63+
[SelectorCandidateFacts(isHittable: false, hasTappableFrame: true)],
64+
allowNonHittableFallback: false,
65+
rawMatchPolicy: .preferHittableMatch
66+
),
67+
.noMatch
68+
)
69+
}
70+
71+
func testMaestroSelectorKeepsExpectedPointAndNonHittableFallbackSemantics() {
72+
XCTAssertEqual(
73+
classifyDirectSelectorCandidates(
74+
[
75+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true, containsExpectedPoint: false),
76+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true, containsExpectedPoint: true),
77+
],
78+
allowNonHittableFallback: true,
79+
filtersByExpectedPoint: true
80+
),
81+
.selected(index: 1, usedNonHittableFallback: true)
82+
)
83+
}
84+
85+
#if os(iOS)
86+
func testQuerySelectorPrefersHittableMatchOverNonHittableDuplicate() throws {
87+
let duplicateIdentifier = "agent-device-selector-read-duplicate"
88+
app.launchArguments = ["--agent-device-selector-read-regression"]
89+
app.launch()
90+
defer {
91+
invalidateCachedTarget(reason: "unit_test_cleanup")
92+
app.terminate()
93+
}
94+
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))
95+
96+
let matches = app.descendants(matching: .any)
97+
.matching(identifier: duplicateIdentifier)
98+
.allElementsBoundByIndex
99+
.filter(\.exists)
100+
XCTAssertEqual(matches.count, 2, "fixture must expose two raw identifier matches")
101+
XCTAssertEqual(matches.filter(\.isHittable).count, 1, "fixture must expose exactly one hittable match")
102+
103+
let command = try JSONDecoder().decode(
104+
Command.self,
105+
from: Data(
106+
#"{"command":"querySelector","commandId":"query-selector-duplicate","selectorKey":"id","selectorValue":"agent-device-selector-read-duplicate"}"#.utf8
107+
)
108+
)
109+
let response = try executeOnMainPrepared(command: command, activeApp: app)
110+
111+
guard response.ok else {
112+
XCTFail(String(describing: response.error))
113+
return
114+
}
115+
XCTAssertEqual(response.data?.found, true)
116+
XCTAssertEqual(response.data?.nodes?.count, 1)
117+
XCTAssertEqual(response.data?.nodes?.first?.identifier, duplicateIdentifier)
118+
XCTAssertEqual(response.data?.nodes?.first?.hittable, true)
119+
}
120+
#endif
121+
#endif
122+
}

0 commit comments

Comments
 (0)