Skip to content

Commit 01b3c74

Browse files
committed
fix: reject distinct ambiguous mutation targets
1 parent a158434 commit 01b3c74

40 files changed

Lines changed: 891 additions & 238 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

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

7+
- 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.
78
- `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.
89
- 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.
910
- `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.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
/// Normal direct selector mutations count every raw exact match before
24+
/// hittability can choose a winner. Maestro's explicitly requested coordinate
25+
/// fallback keeps its point-filtered compatibility behavior.
26+
func classifyDirectSelectorCandidates(
27+
_ candidates: [SelectorCandidateFacts],
28+
allowNonHittableFallback: Bool,
29+
filtersByExpectedPoint: Bool = false
30+
) -> DirectSelectorCandidateDecision {
31+
let eligible = candidates.indices.filter { index in
32+
!filtersByExpectedPoint || candidates[index].containsExpectedPoint
33+
}
34+
35+
if !allowNonHittableFallback {
36+
guard eligible.count <= 1 else { return .ambiguous }
37+
guard let index = eligible.first, candidates[index].isHittable else { return .noMatch }
38+
return .selected(index: index, usedNonHittableFallback: false)
39+
}
40+
41+
var hittableIndex: Int?
42+
var fallbackIndex: Int?
43+
for index in eligible {
44+
let candidate = candidates[index]
45+
if candidate.isHittable {
46+
guard hittableIndex == nil else { return .ambiguous }
47+
hittableIndex = index
48+
} else if candidate.hasTappableFrame {
49+
guard fallbackIndex == nil else { return .ambiguous }
50+
fallbackIndex = index
51+
}
52+
}
53+
if let hittableIndex {
54+
return .selected(index: hittableIndex, usedNonHittableFallback: false)
55+
}
56+
if let fallbackIndex {
57+
return .selected(index: fallbackIndex, usedNonHittableFallback: true)
58+
}
59+
return .noMatch
60+
}

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

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -149,35 +149,31 @@ extension RunnerTests {
149149
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
150150
}
151151

152-
var matchedElement: XCUIElement?
153-
var nonHittableElement: XCUIElement?
154152
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
153+
.filter(\.exists)
154+
let facts = matches.map { element in
155+
SelectorCandidateFacts(
156+
isHittable: element.isHittable,
157+
hasTappableFrame: hasTappableFrame(app: app, element: element),
158+
containsExpectedPoint: expectedPoint.map(element.frame.contains) ?? true
159+
)
172160
}
173-
if let matchedElement {
174-
return SelectorElementMatch(element: matchedElement, isAmbiguous: false, usedNonHittableFallback: false)
161+
switch classifyDirectSelectorCandidates(
162+
facts,
163+
allowNonHittableFallback: allowNonHittableFallback,
164+
filtersByExpectedPoint: expectedPoint != nil
165+
) {
166+
case .noMatch:
167+
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
168+
case .ambiguous:
169+
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
170+
case let .selected(index, usedNonHittableFallback):
171+
return SelectorElementMatch(
172+
element: matches[index],
173+
isAmbiguous: false,
174+
usedNonHittableFallback: usedNonHittableFallback
175+
)
175176
}
176-
return SelectorElementMatch(
177-
element: nonHittableElement,
178-
isAmbiguous: false,
179-
usedNonHittableFallback: nonHittableElement != nil
180-
)
181177
}
182178

183179
// Maestro-compat gate for the non-hittable coordinate fallback: an element
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
func testMaestroSelectorKeepsExpectedPointAndNonHittableFallbackSemantics() {
28+
XCTAssertEqual(
29+
classifyDirectSelectorCandidates(
30+
[
31+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true, containsExpectedPoint: false),
32+
SelectorCandidateFacts(isHittable: false, hasTappableFrame: true, containsExpectedPoint: true),
33+
],
34+
allowNonHittableFallback: true,
35+
filtersByExpectedPoint: true
36+
),
37+
.selected(index: 1, usedNonHittableFallback: true)
38+
)
39+
}
40+
#endif
41+
}

docs/adr/0011-interaction-guarantee-contract.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
Accepted (implemented through Layer 3, 2026-07-04: #1080, #1082#1086, #1091, #1092)
5+
Accepted (implemented through Layer 3, 2026-07-04: #1080, #1082#1086, #1091, #1092; ambiguity contract amended 2026-08-07)
66

77
## Context
88

@@ -64,7 +64,7 @@ every cell to be classified:
6464

6565
```ts
6666
export const INTERACTION_GUARANTEES = [
67-
'disambiguation', // visible > deepest > smallest; ties fail
67+
'disambiguation', // collapse one equivalent wrapper chain; distinct subtrees fail with candidates
6868
'occlusion', // covered targets are refused
6969
'offscreen', // tap point (rect center) must lie in the root viewport
7070
'nonHittable', // promotion + targetHittable/hint annotation
@@ -239,6 +239,23 @@ the daemon there destroyed every healthy app session the daemon owned.
239239
press guarantees"), which matters for small-model agents that only read the
240240
contract, never the code.
241241

242+
### 2026-08-07 amendment: mutating ambiguity fails fast
243+
244+
Element-14 realized the matrix's previously owned success-path gap: four exact
245+
`Team Standup` matches in distinct accessibility subtrees reached ordinary
246+
runtime selector resolution, whose visible/depth/area ranking silently chose a
247+
different semantic target. Mutating selectors no longer use geometry to choose
248+
among distinct subtrees.
249+
250+
The replacement contract is structural. Multiple matches collapse only when
251+
all matches form one ancestor–descendant chain and every member resolves to the
252+
same actionable node. Otherwise the mutation fails with `AMBIGUOUS_MATCH`, a
253+
bounded list of snapshot candidate lines, and a partial ref frame generation so
254+
the caller can retry one listed candidate immediately. The direct XCTest path
255+
counts all raw exact matches before hittability can select a winner and delegates
256+
multiple matches to the runtime classifier; Maestro's explicit expected-point /
257+
non-hittable compatibility path remains intentionally separate.
258+
242259
### Synthesized iOS gesture policy
243260

244261
Synthesized iOS gestures (`scroll`, synthesized coordinate `tap`, synthesized

0 commit comments

Comments
 (0)