65 Commits

Author SHA1 Message Date
Michał Pierzchała 04052fdcd2 fix(android): retire recording evidence stranded by a re-adopted device id (#2564)
* fix(android): retire recording evidence stranded by a re-adopted device id

Android `record start` refused forever with "native recovery evidence already
exists" once an emulator was re-adopted under a new serial: the device-side
marker names the device identity that wrote it, reconciliation retired evidence
only when that identity matched, and the leftover classified as neither
recoverable nor retireable — an `UNKNOWN` internal error whose hint asked for a
bug report, while `record stop` owned nothing to clear.

Start reconciliation now retires evidence whose recording is terminal or whose
device identity the transport can no longer address, and only after every
artifact it names is proven released: a committed recorder through process
inspect, an uncommitted pending artifact against the recorders running on the
device. A recorder still writing is never deleted; the refusal names the writer
in `details.writer`. Remaining refusals — unreadable evidence, the other
transport mode, and an open recording this identity owns — are typed errors
carrying the marker path and the command that clears it.

Closes #2550

* fix(android): keep an unreadable recorder off the delete path

The writer probe filtered candidate processes down to the ones it could prove
were recorders, so a caller that read an empty list as "nothing writes this
path" removed an artifact from under a recorder whose /proc entries could not
be read, along with the marker that named it. A process table it could not read
at all threw a bare Error, which reached callers as an unclassified failure.

The transport answers a writer search with `clear`, `found`, or `uncertain`.
Only `clear` proves an artifact is free: start retirement refuses with
`native_recording_recorder_unproven` and owned cleanup keeps the evidence
pending, so both wait for a conclusive answer instead of deleting on doubt.

* fix(android): keep a mixed writer scan inconclusive

The writer search answered `found` as soon as it identified one recorder, which
hid the candidates it could not read. Start reconciliation already refuses a
found writer, but owned cleanup stops only the recorders it was handed and then
removes the artifact and marker — deleting under an unreadable recorder still
writing that same path.

A search now reports the writers it identified together with whether every
candidate was read. Cleanup requires both halves: an identified recorder does
not prove the others are gone, so an inconclusive scan retains the evidence
before anything is signalled or removed.

* test(android): record retirement side effects through one evidence rig

21 reconciliation scenarios each rebuilt the same marker reader and the same
recording transport stubs. One rig holds the marker and the ordered side-effect
log, so a test names only the probe outcome it is about.

* test(android): keep legacy reconciliation scenarios as they were

The scenarios that already covered retirement were rewritten into a shared rig,
which spent most of this PR's churn budget on moving lines around. They read
from the device marker again as before; only the recorder-state table names the
outcome each state now produces, and the stale row that expected evidence naming
another device identity to be kept is gone, since retiring it is this fix.
New scenarios use the rig.

* test(android): table-drive the inconclusive writer scans

Each inconclusive writer-scan scenario rebuilt the same scoped adb fake and the
same cleanup transport. The transport cases now differ only in which candidate
reads fail, and the owned-cleanup cases differ only in the scan they return, so
both run from one table against the same assertions.
2026-09-14 11:52:56 +02:00
Michał Pierzchała 508b750fbd fix(apple): carry the runner's sparse verdict through a viewport refusal (#2572)
* fix(apple): carry the runner's sparse verdict through a viewport refusal

A payload the runner declared sparse carries the backend, reason code, and reason that explain it, yet the daemon dropped all of it while reconstructing a viewport from the synthetic root that sparse payloads always carry. Callers saw only an internal engine invariant. The verdict now travels as error.details.snapshotQuality and the hint composes the shared sparse-capture advice with the presented surface host.

* fix(replay): keep the capture quality verdict in divergence details

The replay failure wrapper rebuilds cause details from a four-key allowlist, so the verdict a device-facing capture produced could not reach the agent that replayed the step.
2026-09-14 11:34:36 +02:00
Michał Pierzchała 8e8eeb2ced refactor(snapshot): drop the bridge truncation dimension; stop promising --scope for depth caps (#2511)
The dimension and limit the bridge adapter inferred for a cut capture had
no renderer and one consumer, the comparison-identity string, where the
kind alone gives the same comparability. The runner never produced them.

The depth-cap warning suggested --scope to read deeper content; on iOS
scope narrows presentation and acquisition stays scope-blind.
2026-09-12 20:52:49 +02:00
Michał Pierzchała 1527146507 feat(snapshot): disclose a cut capture on every platform; raise the iOS bridge node cap to 5000 (#2510)
Every backend sets truncated: true when it cuts a capture at one of its
limits, but only JSON carried it. One shared warning now renders from that
flag in the cross-platform warnings assembly and tells the agent what fell
off (what comes last in document order) and what to do.

The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android
helper's bound. Measured on a synthetic 600-row screen, acquisition time did
not change with the cap while the 1500 cut dropped the on-screen footer.
2026-09-12 20:52:48 +02:00
Michał Pierzchała 37d67de776 fix(android): carry accessibility selected state into snapshots (#2515)
The snapshot helper never serialized `selected`, and the host reads only the
helper's XML, so no later layer could recover it: `get attrs` had no `selected`
field, no snapshot node was marked selected, `is selected` could not match, and
a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible
condition did not match" for a visible element while `selected: false` matched
every Android node (#2462).

The helper now emits both answers, like `enabled` and `password`, so an
unselected control answers `false` and a helper older than the attribute answers
nothing. The parser, the Android hierarchy node, and the published snapshot node
carry it to `get attrs` and the `[selected]` marker.

Snapshot lines render that marker on the default formatter path too: `--settle`
and `diff` already compared selection, and a line that weighs a fact it cannot
print turns a tab tap into a changed pair whose two lines look identical.
2026-09-12 20:18:35 +02:00
Ahmad Al-Faqih b7c82ea152 fix(test): preserve colliding diagnostic artifacts (#2507)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-12 18:22:50 +02:00
Ahmad Al-Faqih bda6d42c9a fix(test): reject reporter exit codes that can wrap to success (#2497)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 17:37:20 +02:00
Michał Pierzchała 1fb276448f fix(wait): poll through a retriable runner refusal instead of surrendering the budget (#2493)
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget

The iOS Smoke lane started failing on main at the merge of #2486: the new
smoke:webview-remote-content scenario ended with `wait text "Jump to form" 20000`
failing after 288 ms with RUNNER_BUSY. Three defects stacked up.

The scenario reused acceptDeepLinkConfirmationIfPresent, whose readiness landmark
was hard-coded to the Automation lab's text. Off that route it can never match, so
the helper always fell through to its `alert get` probe — and an XCTest alert query
against a live WKWebView screen exceeds the runner's 30 s main-thread execution
watchdog (measured 10.1 s to fail locally, 10.6 s in CI), abandoning main-thread
work and leaving the runner refusing every following command as RUNNER_BUSY. The
landmark is now a parameter and each caller passes its own route's, so the probe
runs only when the destination genuinely did not arrive. The depth-frontier
scenario carried the same mismatch and is fixed with it.

A `wait` is a budgeted retry loop, but it abandoned its whole budget on the first
retriable refusal. A poll whose failure the producer itself marked retriable is now
ridden out like an unreadable capture: the wait keeps polling to its deadline,
records the poll as `retriable` in its timeout evidence, and surfaces the refusal
only when no readable capture ever completed. RUNNER_WEDGED is not retriable and
still ends the wait at once.

That classification was also missing on the path the failure actually took. A
runner error recovered from the lifecycle journal after a lost transport response
was built with a bare toAppErrorCode, so RUNNER_BUSY reached callers as a
RUNNER_BUSY wire code with no `retriable` flag, while the live-response path
published it as COMMAND_FAILED plus details.runnerErrorCode and retriable: true.
Both paths now read the runner's code through one classifier in runner-contract.

Live-validated on a booted iPhone 17 Pro simulator against the fixture app: the
destination landmark resolves in 389 ms with no alert probe, the page wait
succeeds in 81 ms, and the snapshot still carries Link "Jump to form", the
"Email address" field label, and the remote-content-boundary XCTest fallback
warning. Driving the old sequence first reproduces the wedge, after which the
fixed wait polls its full 20 s in `retriable` polls instead of failing instantly.

* test(apple-runner): move journaled runner-code classification to the recovery test

The two new cases landed in runner-command-retry.test.ts, which was already over
the 1,000-line test-file tripwire, so the size ratchet refused its growth. They
assert runnerStatusFailureError's reading of the lifecycle journal, so their home
is runner-command-recovery.test.ts, which mirrors that module and drives recovery
through the real stack against a scripted fake runner.

* test(e2e): let a deep-link route mount before probing for its confirmation alert

The 2500 ms destination budget was tuned to the Automation lab on a warm
simulator. On CI the WebView lab rendered correctly but was not in the bridge
tree that fast, so the helper fell through to its `alert get` probe — and that
XCTest query against a live WKWebView exceeds the runner's execution watchdog,
leaving every later command refused as RUNNER_BUSY.

Measured on a freshly created simulator: with the confirmation alert up the probe
is correct and costs 1.6 s, because the alert blocks the route and there is no web
view to query; with no alert the landmark resolves in 0.1-1.7 s. The budget only
has to outlast an honest mount, and overshooting it costs nothing when a
confirmation really is up, since that route never renders until it is accepted.

Cold-simulator run of the whole scenario: landmark 400 ms, page wait 613 ms,
snapshot keeps the page link, the field label, and the XCTest fallback warning.

* fix(wait): keep the poll timeline on a wait exhausted by retriable refusals

Review finding on #2493: a wait that spent its whole budget being refused threw
the last refusal raw, so the common all-RUNNER_BUSY case carried no captures,
waitedMs or polls and could not show where its budget went — contradicting the
evidence this PR documents. The mirror gap existed on the other exhaustion shape:
when the deadline cancelled the final poll, the wait reported a generic stall and
dropped the runner code and retry details instead.

Both shapes now raise one error that keeps the producer's code, message, hint and
retry details and carries the wait's own evidence, with reason wait_capture_stalled
and the original as its cause. A content verdict is still preserved untouched,
since it already describes the capture it came from, and whether it outranks the
stall verdict stays the caller's policy (wait absent).

Live-verified against a genuinely wedged simulator runner: COMMAND_FAILED,
retriable true, runnerErrorCode RUNNER_BUSY, reason wait_capture_stalled,
captures 6, readableCaptures 0, waitedMs 8041, polls
retriable,retriable,retriable,retriable,retriable,deadline.
2026-09-11 17:37:11 +02:00
Michał Pierzchała fd4cee83f8 fix(android): retire completed recording evidence after pid reuse (#2487)
* fix(android): retire completed recording evidence after pid reuse (#2476)

A reused emulator can reassign a recorded screenrecord pid to an unrelated
process. The transport proves that replacement with `ownership-lost`, but
completed-evidence retirement and reattach accepted only `missing`, so they
treated proven termination like an uncertain live recorder: `record start`
refused forever on the retained marker, and `record stop` could not return
the already-finalized completion.

Classify the declared ownership observations once, in the contract that
declares them, and ask that question instead of comparing to `missing`.
Retirement still refuses a live or unreadable recorder and never signals a
pid it proved is not its own.

* fix(android): read proven termination in the recovery warning too

Review follow-up. Classify the recovered chunk's recorder with the owning
observation predicate, so a recorder proven gone through pid reuse also
discloses that the MP4 may be truncated instead of only a pid directory that
went absent. Attribute each `ownership-lost` producer — reassigned executable,
foreign remote path, exited task with no command line — in the transport test,
and state them in the contract comment the classification rests on.

* fix(android): retain completed recording evidence while a replacement recorder writes its path

Review follow-up. A reused pid that runs screenrecord on the recorded remote
path with a different start time proved the old recorder gone, and retirement
read that as permission to remove the artifact — deleting the replacement
recording's active MP4. Classify that observation as foreign-writer in the
contract: it still proves termination, so recovery and the truncation warning
keep reading it, but it never proves the path unclaimed, so retirement retains
the marker and artifact until the replacement ends and never signals it.
Stop-wait refuses it like ownership-lost.
2026-09-11 15:39:02 +02:00
Ahmad Al-Faqih c08545897c fix(test): expand relative globs from the literal working directory (#2490)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 14:19:22 +02:00
Michał Pierzchała 47b1cae548 fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) (#2486)
* 0.21.1

* fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484)

Since 0.21.0 the host AX bridge is the snapshot source for local iOS
Simulators. It reads one process, and a WebKit page lives in another:
Safari and WKWebView screens were published as chrome plus empty webview
nodes, with no ref reaching the page.

The decoder now counts AXRemoteElement leaves that sit under a WebView
ancestor and reach the viewport, and the source refuses such a tree as
remote-content-boundary. The existing route fallback serves XCTest, which
resolves remote elements, for the rest of the app generation and discloses
the switch in the snapshot warning. Frameless leaves are refused; zero-area
and off-screen ones are published.

Adds a fixture-backed smoke scenario that drives the WebView lab through the
default route, amends ADR 0004 and the bridge README, and shares the e2e
snapshotNodes helper.
2026-09-11 13:12:57 +02:00
Ahmad Al-Faqih 3d503a0e88 fix(test): emit well-formed JUnit XML for replay results (#2477)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 10:41:41 +02:00
Brad Anderson f57b42166a fix(network): report iOS requests that reused a keep-alive connection (#2433)
* fix(network): report iOS requests that reused a keep-alive connection

CFNetwork logs a request URL only on the `com.apple.network:connection`
line that opens a connection. A request that reuses a keep-alive
connection emits a task summary carrying status, timing, and byte counts
but no URL anywhere in the log, so a URL-keyed reader dropped it and the
dump silently omitted a request that did happen. An "assert this endpoint
was called on startup" check therefore read as a definite fail.

Correlate a reused task summary with the connection it names and report
it against that connection's origin, with `pathUnavailable` set, its
status, and its timing. The request path is not in the log at all, so the
dump also notes how many requests it could not name — a gap in
observation now reads as a gap rather than as a negative observation.

Also stop a URL parsed out of a log line from carrying the punctuation
that follows it, so an entry's `url` compares equal to the endpoint under
test instead of failing on a trailing comma.

The correlation lives in the reader rather than a sibling module because
`packages/capture-kit/src/index.ts` may not grow its eager import closure.

Refs callstack/agent-device#2430

* fix(network): count keep-alive requests the reader cannot name at all

Review of the parent commit found the same definite-negative it fixes,
one level down: a reused task summary whose connection was opened before
the scanned window resolves to no origin, so it produced no entry and no
signal — an empty dump reporting "No HTTP(s) entries were found" for a
window that demonstrably carried traffic. Count those in the dump's
`unnamedRequests` and say so in the notes, so an unnameable request is
still a reported observation.

Also order the Apple note builders so the keep-alive note no longer trips
the `notes.length === 0` guard that suppresses lifecycle guidance, and
give the android-backend test a fixture an Apple dump would actually
resolve, so the backend gate it names is the thing it proves.

* fix(network): scope connection correlation to the process that opened it

Review findings on the parent commits: three ways the reader still answers
with something other than what it observed.

A connection number is only meaningful within one process, but the index
keyed on the number alone, so an app that relaunched and reopened the same
number inherited the origin its predecessor had contacted — a request
attributed to a host it never reached, which is worse than dropping it.
Key the index by the compact log's `name[pid]` and the connection number
together; a line whose process cannot be read correlates to nothing and its
traffic stays unnamed.

The simulator recovery pass merged its dump only when it carried entries,
so a recovery window holding nothing but unnameable reused-task summaries
discarded that count and the response still reported an empty window. Merge
whenever the pass observed traffic in either form, and reserve the "none
looked like HTTP traffic" note for a pass that found neither.

The trailing-separator strip was global, so a valid URL ending in
punctuation became a different endpoint. Take the URL from the delimited
`url:` field where the format establishes the separator, and leave a bare
URL exactly as matched.

Regressions cover each: the same connection number under a different pid,
an unreadable process identity, recovery-only unnamed traffic, and a path
that legitimately ends in a period.

* fix(network): reconcile unnamed keep-alive requests across scan windows

The app log and the simulator recovery pass cover different, sometimes
overlapping windows, so taking the larger of their two unnamed counts was
wrong in both directions: two unnameable requests in one window and three
in the other reported three rather than five, and a request the recovery
pass resolved stayed counted as unnamed from the app log.

Carry the identities instead of a count. Every CFNetwork line names its
request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the
same request seen in two windows is recognisable as one. A merge unions the
unnamed identities and subtracts anything either window managed to name, and
a resolved reused request carries its identity as `packetId` so that
subtraction has something to key on.

`NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of
identities is what makes the reconciliation exact rather than a lower bound.

Regressions cover disjoint windows, overlapping windows, and a request one
window named while the other could not.

* fix(network): keep unnamed-request identities out of the response

`unnamedRequestIds` collected every unresolved task in the scan window and
was spread straight into the response, so `network dump 1` could answer
with thousands of task ids: an output whose size tracked the log rather
than the requested entry limit.

The identities exist to reconcile two scan windows, which is a step that
finishes before a dump is returned. Keep them there. `NetworkDump` carries
`unnamedRequests` as a count again, bounded by construction; the identities
ride `ScannedNetworkDump`, the internal widening that the reader and the
merge speak, and the Apple runtime projects them away with
`withoutScanIdentities` on the way out.

Reconciliation is unchanged: overlapping windows still collapse to one
request and disjoint windows still sum, because the merge still sees the
identities and recomputes the count from them.

Regression: five unnameable tasks against `maxEntries: 1` reports all five
and exposes no identity list.

* fix(network): return scan identities beside the dump, not on it

The Apple route stopped leaking task identities into its response, but
Limrun and WebDriver return the scanner result directly and both serve
Apple sessions, so an iOS `network dump 1` through either still answered
with every unresolved task id in the scan window. Projecting at one
producer was never going to hold: `ScannedNetworkDump` was assignable to
`NetworkDump`, so returning the scanner result compiled everywhere and
each producer had to remember not to.

Take the shape away instead. `readRecentNetworkTrafficFromText` returns a
`NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside
the public dump rather than on it, and `mergeNetworkScans` reconciles the
pair. A route returning `scan.dump` cannot carry them out, and a route that
forgets does not compile. All four producers are updated; the response
shape is unchanged.

Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
2026-09-10 20:44:16 +02:00
Ahmad Al-Faqih 72ccf1a476 fix(maestro): use canonical deep-link classification for exports (#2463)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-10 20:43:18 +02:00
Michał Pierzchała dbfebd4e8f refactor(cli-schema): derive the flag tail of usage synopses (#2456)
A command that hand-wrote its synopsis had to restate every option it
accepts inside that string, which is the last restatement left on the help
surface after #2421 made the flag declaration own the option itself.

A synopsis is now grammar plus a generated `[label]` tail, and the two
rendering rules live on the declaration rather than per command:

- the tail names an option with its declared `usageLabel`, alias included,
  the token the `Command flags:` section already shows;
- `usageHidden: true` keeps a cross-cutting opt-in out of every synopsis;
  `--record` is the one today, and it stays under `Command flags:`.

`usageFlags` is where a command states that its synopsis names fewer options
than it accepts: `[]` for a synopsis that is pure grammar or writes its own
mutually-exclusive brackets, otherwise the subset it names. `Command flags:`
still documents everything in `allowedFlags`. Adding an option to a command
therefore updates `--help` on its own, except where the command said its
synopsis stays short.

`snapshot` and `proxy` drop their override; `daemon`, `device`, `doctor`,
`prepare`, `tv-remote`, `scroll` and `artifacts` drop the flag brackets from
theirs. Guards fail a tail that names an option the command does not accept,
or one the hand-written grammar already wrote.

Every synopsis except `snapshot` and `is` is byte-identical; those two move
exactly per the rules above, and the canonical `snapshot` docs line follows
the generator.

Closes #2444
2026-09-10 16:31:08 +02:00
Ahmad Al-Faqih 3bbeb61917 fix(snapshot): compare unchanged presentations by value (#2442)
* fix(snapshot): compare unchanged presentations by value

* test(android): verify unchanged snapshot output live

---------

Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-10 14:12:41 +02:00
Michał Pierzchała bd42b2602f fix(ios): serve regular --depth from every snapshot backend (#2431)
* fix(ios): serve regular --depth from every snapshot backend

A regular depth-capped request was refused on every runner backend but the
recursive tree: the query sweep past depth 1 and private AX at any depth
returned no capture, so a plan pinned or deferred to private AX (custom
actions, a private AX verdict on the session, the XCTest channel penalty)
fell through to the synthetic sparse root, which the daemon then rejected as
"regular iOS snapshot presentation requires a valid viewport".

Presentation already applies the presented-depth cut to whatever hierarchy a
backend acquired, and a depth-capped regular capture is a subset of the
unscoped one from the same backend, so the refusal protected nothing the
unscoped answer did not already disclose through truncated/effectiveDepth.
Delete the gate, declare private AX as regular-depth=presentation-cut, and
record the rule in ADR 0004.

Closes #2403

* test(ios): prove a private-AX-pinned plan serves regular --depth through acquisition

The presentation-package test passes with the old backend depth gate restored,
because it calls presentation directly. This runner-bundle test pins private
AX, asks for regular depth 1 against the launched host app, and requires the
plan to reach acquisition and presentation: a private-ax verdict that is not
sparse, more than one node, a real root rect, and a payload no larger than the
unscoped capture from the same backend. With the gate restored the plan logs
SNAPSHOT_BACKEND_DEPTH_UNSUPPORTED and returns the zero-rect sparse root, and
the test fails.
2026-09-10 11:21:01 +02:00
Ahmad Al-Faqih ee116079ba feat(maestro): export replay flows that switch apps (#2407)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-09 08:27:40 +02:00
Ahmad Al-Faqih b04bc28bed feat(maestro): export home actions from replay scripts (#2394)
* feat(maestro): export home actions from replay scripts

* fix(maestro): preserve the synchronous export import budget

---------

Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-08 21:59:21 +02:00
Jiacheng c61b6ed407 feat(remote): add HarmonyOS proxy lease backend (#2266)
* feat(remote): add HarmonyOS proxy lease backend

Signed-off-by: Ark <artin@cat.ms>

* feat(remote): add HarmonyOS proxy lease backend

Signed-off-by: Ark <artin@cat.ms>

* feat(remote): complete HarmonyOS lease backend wiring

Signed-off-by: Ark <artin@cat.ms>

* feat(remote): complete HarmonyOS lease backend wiring

Signed-off-by: Ark <artin@cat.ms>

* fix(remote): accept Harmony runtime hints

Signed-off-by: Ark <artin@cat.ms>

* fix(remote): complete Harmony runtime lease plumbing

Signed-off-by: Ark <artin@cat.ms>

* test(wire): acknowledge Harmony lease additions

Signed-off-by: Ark <artin@cat.ms>

* test(remote): close HarmonyOS lease review gaps

Signed-off-by: Ark <artin@cat.ms>

* fix(runtime): update HarmonyOS support error text

Signed-off-by: Ark <artin@cat.ms>

* fix(remote): preserve HarmonyOS runtime and proxy device identity

Signed-off-by: Ark <raft-mobile-ark@mail.build>

* fix(script): avoid eager contracts import and relocate Harmony test

Signed-off-by: Ark <raft-mobile-ark@mail.build>

---------

Signed-off-by: Ark <artin@cat.ms>
Signed-off-by: Ark <raft-mobile-ark@mail.build>
Co-authored-by: Ark <raft-mobile-ark@mail.build>
2026-09-08 21:59:06 +02:00
Michał Pierzchała 64b7cc45d4 fix(android): return from orientation once the display reports the rotation (#2356)
`orientation` wrote accelerometer_rotation and user_rotation and returned
at once, while the display rotated some time later. On the loaded CI
emulator that takes seconds, and accessibility reads hang meanwhile: the
Android smoke's `wait text landscape` right after `orientation
landscape-left` got a helper request timeout and then no readable
capture for its whole 10s budget, with the failed-step snapshot taken
afterwards already in landscape (PR #2344, run 34025424834).

The command now polls `dumpsys display` for mCurrentOrientation to
match the requested rotation before returning, each probe bounded by
what is left of the 15s settle budget so a stuck probe ends the settle
as a failure. A display that never gets there fails the command with the
observed rotation instead of reporting success; a display that reports
no rotation at all is left to the setting as before. The provider
scenario scripts the display read against the last user_rotation write.
2026-09-07 10:14:09 +02:00
Michał Pierzchała a78b6bb9a3 feat(wait): carry a per-poll timeline in timeout failures (#2344)
* feat(wait): carry a per-poll timeline in timeout failures

A wait timeout said `reason`, `readableCaptures`, and `waitedMs`, so a
failure could not say where its budget went: the runs behind #2343 spent
a 10s budget on one poll (5.8s runner findText on a fresh app, 3.4s of
target discovery, a fallback cancelled at the deadline) and reported the
same `wait_capture_stalled` as a dead runner. The failure details now
carry `captures` and `polls[]`, one entry per poll with `startedMs` on
the wait's own clock, `durationMs`, and a typed outcome (readable,
unreadable, deadline, runner-restart), next to the unchanged reason and
the request-log link. Long waits keep the first five and last twenty-five
polls so the response stays compact.

* docs(wait): name the polling timeout paths that carry the poll timeline

Review follow-up: wait --stable uses its own error builder and a
never-readable strict absence preserves its predicate failure, so the
timeline is documented for the polling timeout paths that emit it.

* feat(wait): carry the poll evidence on the replay landmark-mismatch refusal

Review follow-up. A replayed selector wait refused for a recorded
landmark mismatch threw without the captures/polls evidence, and when
its final poll ended in a runner restart the refusal hid that outcome.
The refusal now carries the same failure evidence a timeout does, next
to its mismatch details; two regressions cover a mismatch followed by a
deadline-cancelled capture and by a runner restart. Docs and changelog
name the refusal alongside the polling timeout paths.
2026-09-06 12:51:36 +02:00
Michał Pierzchała 835af32577 fix(ios): stop charging every capture for a slow Simulator app discovery (#2331)
* fix(ios): stop charging every capture for a slow Simulator app discovery

Since #2279 every eligible Simulator capture resolves its AX-bridge target
first, and a cache miss spawns `simctl launchctl list` through xcrun with
a 3s timeout on the capture's own critical path. On a loaded CI host that
spawn takes longer than 3s, the timeout is not remembered, and the next
capture pays it again. A `wait` issued right after `open` lost its whole
10s budget that way (runner findText on the fresh app, then the probe
timeout, then a fallback capture cancelled at the deadline) and reported
`wait_capture_stalled` with the app already on screen: four iOS Smoke
failures on main and PRs since 2026-09-05 21:00, none before.

Discovery is now single-flight and detached from the capture that starts
it: a capture waits a bounded 1.5s for it, then takes the XCTest fallback
while the probe keeps running with its own 15s budget; later captures
join the in-flight probe or reuse its result. A responsive host still
reaches the bridge on the first capture. The resolver's error names its
reason so the route diagnostic says why the fallback ran.

* fix(ios): one deadline per discovery, exact claims, route-level regression

Review follow-up. The 15s budget was per subprocess (two simctl probes,
then a 3s ps read); discovery now shares one deadline across all three.
The comments and changelog no longer claim "once per app generation":
single-flight removes duplicate discoveries, each capture still waits up
to its 1.5s slice for the in-flight one, and a failed discovery is
forgotten so the next capture starts another. A route-level test runs
the production resolver over a simctl that answers only when released:
the first capture falls back within the slice, the released discovery
then serves the bridge without a second spawn.

* fix(ios): keep the discovery slot until both probes settle

Review follow-up. Promise.all rejected as soon as either probe failed and
the finally then released the single-flight entry while the other probe
was still running, so a failed `simctl list devices` next to a slow
`launchctl list` let every later capture start its own launch-job probe.
Both probes now settle before the discovery does; the regression test
runs three captures against exactly that partial failure and sees one
spawn, then the settled discovery reports the runtime failure and frees
the slot.
2026-09-06 11:33:24 +02:00
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

Two CI flake families on main and PRs since 2026-09-03.

iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the
runner's stampedSnapshotPayload set `truncated: true` on every non-healthy
capture, so a complete private-AX tree taken while the XCTest channel was
penalized as slow (the normal state on a loaded CI host) was reported as
truncated. Nothing consumed that until the strict absence assertion (#2245)
refused truncated captures. `truncated` now tracks completeness only:
payload truncation, a depth-limited capture, or a sparse terminal payload.
The E2E conformance helper asserted the old conflation and now asserts
`truncated === false`; a runner unit test pins the new contract and joins
the targeted list in ios.yml.

Android Smoke, `get text id="automation-alert-result"` selector miss (5 of
5 failures): #2260 replaced a polling wait with a one-shot read right after
`alert dismiss`, and Android's `alert accept|dismiss` returned as soon as
the button was pressed, while the dialog window was still the only thing in
the accessibility tree. They now poll until the same dialog is gone (a
different alert taking its place counts as dismissed), bounded by the
existing action budget, else fail with "did not dismiss the visible alert"
like the iOS runner already does.

* test(provider): model Android dialogs that leave the tree after the alert action

The scripted Android alert scenarios served the same dialog to every
capture, which encoded the old return-after-press behavior; alert
accept/dismiss now confirm the dialog is gone, so a dialog that never
leaves is the failure it should be (covered by a new scenario). The
fixtures now hide the dialog once its button is tapped or Back is sent,
the way the ANR recovery scenario already did.

* test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step

The Android smoke still missed `id="automation-alert-result"` on CI right
after a confirmed dismissal: the daemon opened a fresh helper session for
that read and its 2s capture had no such node, while the same one-shot
read passes locally in 150ms. The fixture's re-render after the button
callback is app timing, so the scenario waits for the outcome text (the
polling landmark #2260 removed) and then pins it to the canary element.

The harness kept only a screenshot, and only for wait timeouts, so the
tree that produced a selector miss was never in the artifacts. Every
unexpected step failure now writes failed-step-N.png and
failed-step-N-snapshot.json next to failed-step.txt.

* test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00
Michał Pierzchała 658f822c40 fix: encode the mcp subcommand in server.json package arguments (#2275)
* fix: encode the mcp subcommand in server.json package arguments

A registry-format launcher (e.g. one consuming /.well-known/mcp.json or the MCP registry entry) starts the server from the package descriptor only; without the positional "mcp" argument it runs the bare CLI instead of the stdio MCP server (bin.ts only starts the MCP server for the mcp subcommand).

Enforce the argument in scripts/sync-mcp-metadata.mjs so sync and the CI/prepack checks (check:mcp-metadata) keep server.json correct, and regenerate server.json.

* test: own the registry launch-argument invariant; add changelog entry

- scripts/__tests__/mcp-metadata.test.ts asserts the checked-in server.json's agent-device npm package entry declares the exact fixed positional mcp argument (and stays stdio-only), so a missing or wrong argument fails the unit lane in both directions. Wired into the unit-core project include list.
- Changelog: user-visible release fix under Unreleased.
2026-09-04 10:53:04 +02:00
Michał Pierzchała 309a5360f5 feat(daemon): add the allocator-held device claim kind (#2263)
* feat(daemon): add the allocator-held device claim kind

ADR 0021 foundations, unit 2. A device an allocator-managed pool owns is held for that identity's
whole pool lifetime, not for a session, so the claim store gains a second record kind whose
principal is an installation instead of a process. Nothing writes one in production yet.

- `AllocatorHeldDeviceClaim` is a SEPARATE record at `schemaVersion: 3` with `kind: 'allocator'`:
  `stateDir` + `allocator.instanceId` + `allocator.identityIncarnationId`, and no
  ownerPid/ownerStartTime/ownerToken/session/workspace/abandonedAtMs — a record carrying any of
  them does not decode. `DeviceClaim` and its v2 files are untouched, so an older daemon never
  meets a changed process-owned record and only ever sees a v3 file for a managed identity, which
  it reads as an unreadable claim record and fails closed on.
- The managed owner is DERIVED from the recorded allocator instance (`allocatorHeldClaimOwner`),
  never stored, so an owner that disagrees with the claim's principal cannot exist.
- `InspectedDeviceClaim` becomes a union: the allocator member carries `allocatorClaim` and
  `claim: undefined`. Every clearing surface — ownership match, abandon, stale release, the
  startup sweep, acquire-path reconciliation, session close, lease expiry, the shutdown ledger,
  and `processOwnsActiveDeviceClaim` — reaches for `claim`, so none of them can be written against
  an allocator-held claim, and `DeviceClaimReconciler` stays typed to the process-owned record.
- New classification `allocator-held`: not stale, not owner-releasable. `device status` shows it
  in the normal view with its allocator, incarnation and installation; `device release --stale`
  refuses it with `allocator-held-owner`.
- The verifier gains `covered` and `incarnation-stale`. A session or transient command whose fence
  names the incarnation the claim holds executes under it, acquiring and clearing nothing; a fence
  for a re-provisioned identity is refused, because an incarnation is stable for that identity's
  pool lifetime.
- The ORDINARY arm of the admission gate now performs a read-only inspection under every policy
  but `none` and refuses `DEVICE_IN_USE` / `DEVICE_CLAIM_ALLOCATOR_HELD`: `apps` and `app-state`
  boot a device through `ensureReady` exactly as a mutation would. It still acquires nothing, so
  the policy/claim-file table is unchanged. That reason is deliberately outside
  `DeviceClaimConflictReason`, so replay never retries it as infrastructure. The inspection reads
  the record and stops (`readAllocatorHeldClaimFile`): the owner-liveness probe `inspectDeviceClaimFile`
  runs costs a host process observation per binding, and this kind has no owner process to observe.
- `acquireAllocatorHeldDeviceClaim` reattaches only on the full principal and never reconciles or
  supersedes an ordinary claim (a conflicting ordinary claim prevents publication, ADR 0021 §4);
  `releaseAllocatorHeldClaim` takes removal proof and is the only clearing path. Both are
  by-design production-unused in this unit, with a comment naming the unit that will call them.
- The record/decoder and the lock/write leaves move out of device-claims.ts into
  device-claim-record.ts and device-claim-store.ts, which dissolves the
  device-claims <-> device-claim-inspection type cycle and lets the allocator module share the
  writer without device-claims.ts ever importing it.

`devices` still reports no `claimedBy` for an allocator-held claim: the public field is
`{ session, workspace }` and this kind has neither.

* docs: condense CONTEXT.md entries to leave room for sibling units

Wording-only tightening of 41 existing definitions. No term is added, removed or redefined, and the
five entries unit 1 added and the three unit 2 touched are left alone. The glossary ends at 11,589
of the 12,000-byte guidance budget, which leaves room for the sibling unit's vocabulary to land
without a second trim.

* fix(daemon): register device-claim-store as the claim file's atomic-publish owner

device-claims.ts no longer writes its claim file directly: it delegates every write to
writeDeviceClaim in the new device-claim-store.ts, the single writer shared by the process-owned
and allocator-held claim kinds. atomic-publish-ownership.test.ts's SIMPLE_PUBLISHERS list still
named device-claims.ts, so its source-grep for `publishFileSync` no longer matched anything and
the ownership gate failed in CI. Register device-claim-store.ts as the owner instead.

Also rename DeviceClaimRecord -> StoredDeviceClaim (and decodeDeviceClaimRecord ->
decodeStoredDeviceClaim): the shutdown ledger's own DeviceClaimRecord in daemon-shutdown-report.ts
is a different type (one row of what teardown released), and the shared name invited confusing
that unrelated type for this module's claim-file record.

* fix(daemon): extend the allocator-held admission exhaustiveness table

The rebase onto adr0021/u1-owner-kind's decideAllocatorHeldAdmission fix carried forward
ADMITTED_BY_OUTCOME_STATUS and its outcomes array from before this branch added the covered and
incarnation-stale statuses, so the table no longer covered the whole AllocatorHeldClaimAdmission
union and TS2739 caught it. Add both: covered admits, incarnation-stale does not.

* fix(daemon): fail closed on a corrupted allocator-looking claim record

readAllocatorHeldClaimFile gated on entry.allocatorClaim, which is only set once a record
decodes all the way through. A v3-schema record corrupted into also carrying a process
principal field fails decodeAllocatorHeldClaim's carriesProcessPrincipal check and
decodeStoredDeviceClaim returns null -- exactly like a record that never existed. Ordinary
admission's inspectAllocatorHeldDeviceClaim then read that as "no allocator claim" and let
observe and every other non-transient-exclusive policy proceed, against a device an
allocator may actually hold.

A record that declares schemaVersion 3 but fails to decode is not provably a non-allocator
record, so it cannot be treated as absent. Add looksLikeAllocatorHeldClaim (device-claim-
record.ts) to distinguish "declares the allocator schema, doesn't decode" from every other
kind of corruption, give it its own classification (allocator-inconsistent), and have
readAllocatorHeldClaimFile return the entry -- not null -- for it. Ordinary admission then
refuses through the existing deviceClaimConflictError path (DEVICE_IN_USE /
DEVICE_CLAIM_OWNER_UNCERTAIN), same as any other claim it cannot verify.

deviceClaimRequiresStaleInspection, deviceClaimOwnerCannotRelease and conflictReason are
exhaustive switches over DeviceClaimClassification, so the new member forced a decision at
every site rather than one that could be missed: not stale (nothing dead to surface), not
owner-releasable (no process proof exists to make), and DEVICE_CLAIM_OWNER_UNCERTAIN like
the other undecodable-record classifications, not a permanent-condition reason.

Planted-red verified: reverting readAllocatorHeldClaimFile's gate to entry?.allocatorClaim
alone makes the new regression fail with error.code 'UNKNOWN' -- admission resolves with no
error at all, exactly the silent pass-through this fixes.

* refactor(daemon): fold staleReleaseRefusalReason into a lookup table

The switch exceeded fallow's health gate at 10 cyclomatic / 31.6 CRAP once the
allocator-inconsistent case joined it. A Record<DeviceClaimClassification, string> reads as
one branch to the complexity walker instead of one per case, while TypeScript still refuses
to compile a missing key -- the same exhaustiveness guarantee a switch gave, at a fraction
of the counted complexity.

* docs: trim CONTEXT.md back under the 12,000-byte guidance budget

Main gained bytes elsewhere since this stack's own docs-condense commit landed, pushing
CONTEXT.md to 12,028. Tighten four of this stack's own entries (managed local owner, request
generation, identity incarnation, managed device allocator port) rather than touch anyone
else's; 11,947 bytes leaves headroom against the next PR that lands first.
2026-09-03 19:41:53 +02:00
Michał Pierzchała a4f625c774 feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling

* fix: keep wait absent coverage gates green

* fix: preserve wait absent restart diagnostics
2026-09-03 14:01:15 +02:00
Michał Pierzchała 2c7fb93cfc fix(android): apply settings airplane through the connectivity service (#2234)
* fix(android): apply settings airplane through the connectivity service

settings airplane wrote airplane_mode_on and then broadcast
android.intent.action.AIRPLANE_MODE, which Android refuses for non-system
callers. The write landed, the broadcast failed, and the device reported
airplane mode with the radios still up.

The connectivity service now owns the change: it is read to prove the build
supports airplane mode before anything is written, driven with
cmd connectivity airplane-mode enable|disable, and read again so the response
reports the mode connectivity holds rather than the one requested. Builds
without that command are refused unmutated with UNSUPPORTED_OPERATION.

Closes #2223

* test(android): pin the mechanics eager closure at 178 modules

Splitting the airplane owner out of settings.ts adds one module to the
mechanics facet, which is implementation-eager by design. The row moves to the
measured number in the PR that grows it.

* fix(android): report only capability absence as unsupported airplane mode

An unrecognized nonzero probe — a permission denial, a connectivity-service
error — was answered with "requires Android 11; use a newer device". Only the
prose adb prints when a build ships no shell implementation for the command
now selects UNSUPPORTED_OPERATION; every other failed read stays
COMMAND_FAILED with its classified hint, and the write is unreachable from
both.

The predicate that reads that prose already existed for the clipboard service
and is now named for the question it answers, so airplane mode reuses it
instead of adding a second message sniff.
2026-09-02 16:54:19 +02:00
Michał Pierzchała b8ce09db73 refactor(ios): route Appium snapshots through engine (#2224)
* refactor(ios): route Appium snapshots through engine

* perf(ios): keep Appium snapshot adapter lazy

* fix(ios): preserve legacy snapshot presentation boundary

* perf(ios): tighten Appium snapshot facts

* test(ios): cover legacy snapshot presentation boundary

* test(ios): cover WebDriver snapshot seams

* fix(ios): centralize WebDriver snapshot evidence

* fix(ios): preserve unavailable snapshot facts

* fix(ios): preserve snapshot error context

* fix(ios): centralize snapshot presentation ownership

* fix(ios): disclose Appium snapshot limits accurately

* fix(ios): disclose Appium snapshot evidence limits

* fix(ios): harden Appium evidence disclosure

* fix(ios): tighten Appium snapshot disclosures

* fix(ios): close Appium audit gaps
2026-09-02 07:58:47 +02:00
Michał Pierzchała 315a0ac679 fix(daemon): fail closed when the auth hook is silent about tenant (#2104)
An auth hook that ran but returned no tenantId opted the deployment into
tenant attestation; falling back to the client's own claim (RPC body
meta.tenantId, aux-route x-agent-device-tenant header) let a holder of one
valid shared token impersonate any tenant on /rpc and on the diagnostics/
upload/download routes. resolveTrustedTenant() in the new
src/daemon/server/tenant-trust.ts is now the single seam both surfaces go
through and the only place that computes the resulting identity: hook
attests -> use it; no hook configured -> keep today's client-declared
behavior (loopback/dev unchanged); hook configured but silent with a
client-declared tenant -> refuse (401) instead of trusting the claim, and
no raw client-declared metadata survives into the dispatched request in
that case either.

Fixes #2095
2026-08-28 11:50:02 +02:00
Michał Pierzchała 74a70f1764 refactor: remove next-major compatibility surfaces (#2046) 2026-08-26 14:14:15 +02:00
Michał Pierzchała 67b813c55b fix(web): launch npm and the managed backend through node, not .cmd shims (#2033)
On Windows every `--platform web` command failed with `spawn EINVAL`: the
managed backend resolved to `node_modules/.bin/agent-browser.cmd` and was
spawned with `shell: false`, which Node refuses for `.bat`/`.cmd` since the
CVE-2024-27980 fix. `web setup` failed earlier still — a bare `npm` is not
spawnable on Windows, where npm ships as `npm.cmd`.

`runManagedAgentBrowser` is now the only path that executes the backend. Entry
resolution, the Node runtime, the managed environment, and the spawn all live
behind it, so setup, doctor, and the provider cannot reintroduce the shim. The
entry comes from the installed package's declared `bin` rather than a hard-coded
path, which is the part of this worth being precise about.

npm is untouched on macOS and Linux, which were never broken: setup still spawns
`npm` from PATH. Only Windows resolves npm's own `npm-cli.js` — from an
`npm_execpath` that really is npm's launcher, else the copy bundled beside
`node` — and fails with the existing actionable TOOL_MISSING when neither is
there. Setup also pins `--no-global` so an ambient `npm_config_global` cannot
redirect the install out of the managed prefix.

The published status shape is unchanged: `binaryPath` still names npm's console
shim, now informational rather than the spawned command, and `entryScript` plus
`packageDir` are additive.

Closes #2022


Claude-Session: https://claude.ai/code/session_01LMS3BidXb3F4HSr26vvQmG

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 17:59:44 +02:00
Michał Pierzchała 608bf7aa47 Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version

Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).

- release:publish now runs release:mark-dev after npm publish, moving
  package.json and the synchronized server.json to the next patch with a
  -dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
  a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
  0.20.10 while the tool surface kept changing, which is the live finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): remove credential inputs from the model-writable tool surface

Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:

- the keys are omitted from every advertised tool schema (MCP and AI SDK,
  which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
  forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
  and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
  / AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts

Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.

Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* feat(ci): inventory every dynamic value reaching a device shell

adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.

scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(shell-argv): satisfy the fallow audit without suppressions

The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(test): close the port-reuse race in the unreachable-takeover test

Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): enforce the advertised tool schema at a real admission boundary

P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.

Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.

Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* revert(ci): remove the check:shell-argv inventory gate

The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.

Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 15:19:07 +02:00
Michał Pierzchała 775eddd749 feat: session-scoped echo protection for parameterized recorded inputs (#2013)
* feat: session-scoped echo protection for parameterized recorded inputs

Extends ADR 0017's fill-step-scoped guarantee to the whole recording
session (#1398). After #1349, a later read-only action (`wait`, `is`,
`get`) can independently observe and record an app-rendered echo of an
already-parameterized `fill --record-as` value in its own result or
target-v1 identity evidence, re-leaking the literal even though the
originating fill was protected.

- SessionState gains a small, ephemeral, never-serialized
  literal->placeholder registry populated only from explicit
  `--record-as` pairs, owned by session-action-recorder.ts.
- Result/event payload fields get content-aware substring redaction
  (reusing the fill boundary's recursive scrub) for every literal
  registered so far in the session, longest-literal-first.
- target-v1/targets-v1 identity evidence is never silently
  text-substituted while still claiming a trustworthy identity (replay
  compares against the live tree, which re-renders the real value).
  A landmark-mode (wait) echo is dropped to no annotation, exactly like
  #1349's existing identity-empty case, so an echoing landmark can no
  longer serve as an ADR 0016 destination guard. Action-mode evidence
  (get/is/mutating actions) redacts the label and downgrades
  verification to "unverifiable" instead, since ADR 0012/0016 forbid
  dropping required identity evidence.
- Amends ADR 0017 (new mechanism), ADR 0012 (#1349/writer-invariant
  cross-references), and ADR 0016 (destination guard cross-reference).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui

* fix: placeholder-safe single-pass multi-literal redaction

Addresses review feedback on #2013: sequential single-literal
replacement (register somethinglong -> ${ABC}, then ABC -> ${OTHER})
could rewrite a placeholder produced by an earlier pass, corrupting it
to ${${OTHER}}.

Replaces the per-pair sequential loop with one placeholder-safe
left-to-right multi-literal pass (parameterizeAgainstLiteralMap): it
never re-scans text it has already emitted, so no literal can be
matched inside another pair's placeholder token in either direction.
A registered literal is matched before checking for an existing
placeholder token, so a value that itself happens to look like
${SOMETHING} is still redacted correctly. The scan uses a sticky regex
instead of slicing per character, and literal pairs are sorted once
per payload/evidence walk instead of once per string leaf.

parameterizeRecordedFillPayload/parameterizeBackendOutput are
generalized to take injected leaf-transform/carries callbacks so the
single-pair fill-boundary path (with its existing whitespace-collapse
behavior) and the new multi-pair session-wide path share one
structural traversal.

Adds regression coverage for both result payloads and action-mode
target evidence, plus the placeholder-shaped-literal edge case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui

* fix: unexport parameterizeAgainstLiteralMap (CI: fallow dead-code gate)

Only used internally within this file (by parameterizeRecordedResultEcho
and parameterizeTargetEvidenceEcho); the export had no consumer outside
the module, which the fallow audit correctly flags as dead code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 21:24:02 +02:00
Michał Pierzchała 957a6727f8 fix(android): publish covered state from exact order evidence (#1981)
* fix(android): unify snapshot occlusion across API levels

* fix(android): preserve exact occlusion evidence

* fix(android): restore collective occlusion coverage

* fix(android): preserve snapshot evidence across consumers
2026-08-24 18:09:05 +02:00
Michał Pierzchała 7f3e355426 fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
* fix(ios): complete regular snapshot depth frontier

* fix(ios): align depth frontier with visibility fold

* fix(ios): exercise regular depth frontier in CI

* fix(ios): cover visible-depth frontier through public snapshot

* fix(ios): tolerate absent deep-link confirmation

* test(ios): expose visible-depth fixture hierarchy

* test(ios): wait for visible-depth fixture subtree

* fix(ios): keep visible-depth fixture minimal

* fix(ios): update snapshot hint fixtures

* test(ios): avoid fixture label aggregation

* test(ios): match fixture raw hierarchy

* test(ios): prove visible-depth raw ancestry

* test(ios): align depth smoke with AX hierarchy
2026-08-22 13:53:39 +02:00
Michał Pierzchała af96c6608d feat(ios): publish effective snapshot geometry (#1931) 2026-08-21 11:27:04 +02:00
Michał Pierzchała 73db7be2ff feat(ios): move the regular-projection clip fold into snapshot presentation (#1797) (#1929)
* feat(ios): move the regular-projection clip fold into snapshot presentation

Both iOS snapshot backends carried their own copy of the visibility fold: the
tree walker and the private-AX serializer each computed viewport-and-scroll-clip
intersection, ancestor projection, hidden-content hints, and collapsed depth
during acquisition. Hand-synchronized copies of that interpretation are what
produced the scroll-overflow leak class (#1784), and C1 (fact-availability
neutrality) could not hold while acquisition decided what a screen shows.

Acquisition backends are now fact serializers: every traversed node is emitted
at raw traversal depth with its reported frame, and SnapshotAcquisition carries
the viewport. presentRegular runs the one clip fold for every backend --
viewport ∩ scroll clip, the ancestor cursor (an out-of-clip Cell or scroll
container hides its clamped descendants), the sub-pixel decoration rule,
scroll hints booked onto anchors, reparenting with collapsed depth -- and
narrows the emitted hittable to the clip: nothing outside its clip, and nothing
without geometry, is ever hittable, whatever the backend reported. Platform
differences are a SnapshotFoldPolicy input to the shared algorithm (iOS
cursor-projected; macOS/tvOS plain viewport), never a backend exception.

The private-AX backend collapses to ONE serializer for both projections, and
the flat filter-decision family dies with the acquisition gates it fed.

Three intentional edge deltas, each toward one backend-neutral rule: sub-pixel
content-free decorations now drop on every backend (was private-AX only);
labeled offscreen Application/Window carriers survive on every backend (was
tree only), never hittable; query-sweep regular without -i is viewport-folded.
Declared acquisition residues: the traversal-depth budget cut, the sweep's
frameless-element drop, the private-AX bridge's device-side cap.

Refs #1797 (migration step 3, clip-fold delta).

* refactor(ios): isolate snapshot visibility fold
2026-08-21 11:27:04 +02:00
Michał Pierzchała 4137e4275e feat(ios): split raw and regular snapshot projections behind one capture hint (#1926)
The private-AX backend interpreted `--raw` as the regular projection: it folded
the viewport and scroll clips and dropped sub-pixel decorations before returning,
so a raw capture that recovered onto it answered with viewport-pruned nodes
labeled raw (#1797 D4). Nothing related the two copies of that decision.

Presentation now exposes the two projections it always implied. `presentRegular`
folds visibility, eligibility, scope and scroll hints; `presentRaw` is the
acquired tree, normalized, narrowed only by a scope or depth the request asked
for. Acquisition reads one derived `CaptureHint` instead of the request itself,
so what a capture may skip is stated once, beside the reason skipping it keeps
the projection complete: scope and its relative depth never narrow, raw depth
does (raw depth is traversal depth), and the raw projection never carries
`interactiveOnly` — `--raw -i` is the acquired tree.

Two structural rules replace the hand-synchronized ones. The raw plan is derived
from `SnapshotBackendKind.supportsRawProjection`, so the query sweep — an
interactive element query with no hierarchy to return — cannot be planned for a
raw request. And presentation compares the requested projection with the hint
the acquisition was captured under, dropping that tier with a structured
`IOS_SNAPSHOT_PROJECTION_MISMATCH` failure rather than presenting it under the
requested label.

Declared residue: a regular `--depth` request still cuts the traversal at that
depth while regular presentation emits collapsed depth, so a node that would
present within the limit can be dropped. The cut is what keeps `--depth 1`
probes cheap; making it complete is the open visible-depth frontier obligation.

Refs #1797 (migration step 3, raw-projection delta).
2026-08-20 21:44:48 +02:00
Michał Pierzchała ce71e96912 fix(android): warn when recording readiness inspection fails open (#1898)
* fix: disclose Android recording readiness inspection failures

* test: prove Android readiness warning bounds
2026-08-20 15:18:52 +02:00
Michał Pierzchała 84e6f9bf2d refactor(android): raw is the acquired tree; one presentation for dialog recovery; residues declared (#1832 C3) (#1865)
* refactor(android): raw is the acquired tree; one presentation for dialog recovery; residues declared (#1832 C3)

- C3: the three regular-projection pruners (invisible subtrees, stale application windows,
  covered same-window surfaces) move out of parseUiHierarchyTree into the projection as a
  non-mutating classification (collectAndroidHiddenNodes in ui-hierarchy-visibility.ts). --raw
  presents the acquired tree; interactive ⊆ regular ⊆ raw by construction. Hidden-content hints
  and the scope root are derived per projection over retained children, which is what the
  mutating pruners implied. Property-checked identical to main for regular/-i/depth/scope over
  12,000 random tree × projection pairs; raw grew on 2,514/3,000 and never shrank.
- Android blocking-dialog recovery routes through buildSnapshotState (the one presentation), which
  moves to src/daemon/snapshot-state.ts below the daemon-server type cycle; importers repointed,
  its tests mirror the module.
- Freshness route signature drops role/selected (Android never carries them).
- Residues declared at their sites and in CONTEXT.md; docs + CHANGELOG.
- ui-hierarchy.ts split by question: node predicates (ui-hierarchy-node.ts), regular-projection
  visibility (ui-hierarchy-visibility.ts), scope (ui-hierarchy-scope.ts); 974 → 634 LOC.

* test: lower the snapshot.test.ts size pin to its new length

* fix(android): dialog recovery acts on the presentation's occlusion result

Review P1 on #1865: routing blocking-dialog recovery through buildSnapshotState made the occlusion
result available but nothing consumed it. containsBlockingDialog scanned every node and
findCloseAppButton returned the first text match with a rect, so a stale ANR surface left under the
foreground one could still trigger recovery, and a covered "Close app" could be tapped ahead of the
visible top button — the disagreement the routing was supposed to remove.

Both decisions now filter through isSnapshotNodeInteractionBlocked, the shared predicate over the
annotator's structured result. Two regressions cover it, both proven red against an unfiltered
selection: a covered Close app preceding a visible one (asserts the visible center is tapped) and a
fully covered dialog signal (asserts recovery does not trigger, no tap dispatched).

Rebase reconciliation: screenshot-runtime.ts arrived on main (#1878) importing buildSnapshotState
from its old home; repointed to src/daemon/snapshot-state.ts with the other importers.
2026-08-19 18:17:45 +02:00
Michał Pierzchała 67ce19b50c fix(daemon,kernel): device-selection safety — identity conflicts and ambiguity fail instead of retargeting (#1880)
* fix(daemon): session-lock identity conflicts fail instead of changing device identity

`--session-lock strip` resolved every conflict by deleting the offending selector, including
--udid/--serial/--device. The command then ran against the BOUND session's device rather than the
one the caller named, and the error hint that produced this state actively recommended strip. A
wrong-device tap that reports success is worse than any loud failure, so:

- a conflict on a device IDENTITY selector now fails under both reject and strip; strip keeps
  resolving platform/scope selectors (--platform, --target, --ios-simulator-device-set,
  --android-device-allowlist), which is what it exists for;
- the structured error carries both sides (requestedDevice, boundDevice) so a caller can choose a
  recovery without parsing prose;
- the fresh-session hint offers the two real recoveries — close the bound session if the requested
  device is intended, drop the selector if the bound device is — and never mentions strip for an
  identity conflict. The existing bound-session hint already stated both and is unchanged.

Admission-layer only: this is device/session resolution policy, evaluated before any interaction
dispatch path is selected, so no ADR 0011 guarantee cells move.

One table pins the crossing: fresh vs existing session, matching/conflicting/absent identity,
reject vs strip, binding vs inventory command, Android serial / Android udid / Apple udid, with the
exact error details and hint asserted. 7 of its 13 rows are red against the previous policy.

* fix(kernel): validate platform-specific device flags before resolution

`--udid` enters Apple resolution unconditionally, so `--platform android --udid emulator-5580`
answered "No Apple device with UDID emulator-5580" — an answer about a platform the request had
explicitly excluded, which reads as a missing device rather than a mistyped flag. Both directions
now fail as INVALID_ARGS naming the correct flag (--serial for Android/HarmonyOS, --udid for Apple).

Requests that name no platform keep the existing DEVICE_NOT_FOUND behavior, since nothing
contradicts the selector there.

* fix(kernel): refuse ambiguous singular device resolution instead of picking one

resolveDevice answers with exactly one device, so every caller of it needs one concrete device. When
the request carried no identity and several candidates were equally preferred, it returned the first
by discovery order (or alphabetically for Apple) — a successful response describing a device the
caller never selected. That is worse than any loud failure, and reads are not safer than writes: a
snapshot of the wrong emulator is a wrong answer that looks right.

Ambiguity is now a refusal at the resolver boundary, not a command-kind allowlist:

- established preference tiers are preserved (virtual over physical, the Apple kind/target rank,
  then booted over offline); only what survives them equally is ambiguous, since the comparator's
  remaining tie-breaks are name and discovery order, which encode nothing about intent;
- one booted emulator beside offline candidates still resolves, as do explicit --device/--udid/
  --serial and any command inside an existing session, whose identity is already fixed;
- multi-device commands ("devices") never enter singular resolution;
- the error reuses the declared device-candidate details domain (AMBIGUOUS_MATCH + "devices"), so
  CLI and MCP already render the bounded list, with a hint naming the platform-appropriate selector.

* fix(kernel): drop the mismatched article from the selector-flag hint

"Use --serial X to select a android device by serial" — the platform name is interpolated, so no
article fits every value. Names the flag's platform family instead.
2026-08-19 16:51:44 +02:00
Michał Pierzchała ee13203a16 feat(ios): unify snapshot eligibility (#1850)
Make iOS regular snapshot eligibility one backend-neutral presentation rule.

Acquire tree nodes conservatively, preserve interactive scroll containers, normalize surviving hierarchy, and keep raw membership plus daemon publication policy unchanged. Part of #1797.

- iOS and macOS unit-enabled runner builds
- 2 focused XCTest cases
- 3 production-path publication tests
- live Settings snapshots: 73 regular nodes and 167 raw nodes, both healthy tree captures
2026-08-18 18:57:29 +02:00
Michał Pierzchała 294654a3a5 fix(android): resolve snapshot scope once and disclose the API 23 occlusion-scan gap (#1832 C1/C2) (#1846)
* fix(android): resolve snapshot scope once and disclose the API 23 occlusion-scan gap (#1832 C1/C2)

- Android resolves --scope inside its projection only, under the shared scope specification
  (matchesSnapshotScope in @agent-device/contracts/snapshot: first document-order match over
  label/value/identifier, empty on no match). The daemon post-wire scopeSnapshotNodes pass skips
  the android backend, so scope has one owner and one no-match semantics instead of BFS+fallback
  followed by document-order+empty.
- Golden table contracts/fixtures/snapshot-scope-policy.json is asserted against the predicate,
  the Android projection, and the daemon pass; the Swift runner twin (#1797) consumes the same table.
- androidSnapshot.occlusionScanUnavailable discloses helper trees without drawing-order (API 23),
  where the covered-sibling pruner cannot run. Disclosure only; C1 stays open until occlusion moves
  to the daemon annotator.

* fix(android): resolve scope over the presented tree and stop dropping it on interaction captures

Adversarial review findings on the first commit:

- BLOCKER: captureSnapshotData spread `snapshotScope: undefined` over flags, so an interaction
  capture (press/click/fill/longpress/hover --scope, --settle observation) reached the Android
  platform unscoped while buildSnapshotState still saw the scope. The post-wire pass used to rescue
  it; after skipping android it returned the unscoped tree. One effective scope now feeds both.
- Scope resolves over the PRESENTED nodes of the requested projection, not the acquired tree, so an
  acquired match that membership drops no longer empties the snapshot, and Android matches the
  domain iOS's pass uses.
- Slicing after the walk keeps ancestor context (hittable/collection/chrome) above the scope root,
  so scoped -i is a subset of unscoped -i; --depth stays scope-relative.
- Shared findSnapshotScopeRange/reindexSnapshotNodes so the daemon pass and the Android projection
  run one implementation; scope slice extracted to ui-hierarchy-scope.ts (mirrors its test).
- parseUiHierarchy moved to a test fixture module (it had no production caller left).
- Golden rows sharpened (value row no longer matches via label on Android); the Android leg runs
  raw AND regular. CHANGELOG entry; docs wording corrected for iOS/@ref.

* fix(layering): keep the contracts snapshot façade exhaustive over snapshot-scope

* fix(android): scope to the first match whose subtree still has presented content

Review P1 on #1846: with scope resolved strictly over presented nodes, `snapshot -i --scope panel`
answered "no nodes" whenever the matched container was a structural view membership drops — even
though the button inside it was exactly what was asked for — and `--depth 0` then hid a node the
response prints at depth 0.

The scope root is now the first document-order match whose subtree contributes at least one node to
the requested projection, and the result is that subtree's presented nodes re-rooted at depth 0.
Both failure modes die: a decorative match membership drops no longer empties the snapshot, and a
dropped container still scopes to its content. `--depth` under scope filters the depths the
response emits, so a node shown at depth 0 survives `--depth 0`.

Tests: the golden legs stay raw+regular (bare TextViews cannot survive -i, so an -i leg would
measure membership, not scope) with the projection interplay pinned by two dedicated tests on
actionable shapes; the 'not re-scoped after the wire' case now runs a real parsed scoped tree
instead of fabricated depth-0 siblings.
2026-08-18 18:44:13 +02:00
Michał Pierzchała 6a8beb653e feat(mcp): compact server instructions in both eras + MCP-only help tool (#1839)
* feat(mcp): compact server instructions in both eras + MCP-only help tool (#1833)

MCP-only clients got no workflow guidance: server/discover carried two
sentences, legacy initialize carried nothing, and the CLI guides
(agent-device --help, help <topic>) were unreachable over MCP.

- MCP_SERVER_INSTRUCTIONS: one MCP-phrased workflow card (<2 KB, the
  Claude Code truncation limit) returned by server/discover and legacy
  initialize alike.
- help tool, router-owned (not a command descriptor): no topic -> the CLI
  decision card; topic -> agent-device help <topic|command> text, prefixed
  with the one-line CLI->tool-property mapping; unknown topic -> isError
  listing the topics. listCommandTools() stays descriptor-only for the AI
  SDK; the router composes descriptors + help.
- Move src/cli/parser/cli-help{,-overview}.ts to src/cli-schema/ so
  src/mcp (rank 3) can import the renderers without a layering back-edge
  into src/cli (rank 6).

* fix(mcp): name terminal-only commands in help guides; colocate cli-help tests with their sources

- The MCP guide preamble claimed every `agent-device <command>` line is a
  tool of that name; `help web` tells the reader to run `web setup` /
  `web doctor` and no `web` tool exists. The preamble now lists the exact
  CLI-only set (listCliCommandNames minus listMcpExposedCommandNames) —
  derived, not scanned out of prose where `device`/`web` are ordinary
  words. Regression: help web names `web` as terminal-only, and the listed
  set equals the registry difference.
- cli-help-*.test.ts move from src/cli/parser/__tests__ to src/cli-schema/
  to mirror the moved sources.

* perf(mcp): tighten the guide card, tool description, and preamble

Instructions card 1572 -> 1378 bytes (paid every session), tool
description and preamble trimmed, HELP_TOOL built once as a const.
Bundle delta vs main 3189 -> 2715 bytes; the remainder is the guide text
itself, which the bundle carried in no MCP-phrased form before.
2026-08-18 17:48:36 +02:00
Michał Pierzchała 8db36299e4 feat(web): add hover command for hover-gated UI (#1783) (#1786)
* feat(web): add hover command for hover-gated UI (#1783)

Add a first-class `hover <x y|@ref|selector> [--settle]` verb, admitted on
web only, that moves the pointer without pressing via the agent-browser
backend (mouse move). It rides the existing targeted-touch pipeline
(ref/selector/coordinate resolution, occlusion/off-screen guards, settle
observation, response builder, recording) through a new optional
Interactor/backend `hover` op that only the web provider implements.

Touch platforms have no hover state: capabilities advertise it on web
only and iOS/Android/Linux reject it at admission with a --platform web
hint; longpress stays the mobile hold-gesture verb.

Closes #1783

* fix(hover): native hoverRef route for web @ref, android coverage pin, revert skill edit

Review follow-ups on #1786:
- hover @ref on web now dispatches through the provider's own element handle
  (agent-browser `hover <ref>`) via a new backend hoverTarget, mirroring
  click/fill's ADR 0011 native-ref path — web ref frames carry no rects, so
  the coordinate route could never resolve them. The shared preflight +
  exact-ref dispatch is extracted into dispatchNativeRefInteraction and used
  by tap/fill/hover; the guarantee matrix native-ref row now lists hover.
- Daemon regression test is production-faithful: rect-less web ref frame,
  scoped provider, asserts no coordinate dispatch. Selector→coordinate and
  provider hoverRef tests added.
- Android emulator coverage summary pin 2/53 → 3/54.
- skills/agent-device/SKILL.md reverted (out of scope, AGENTS.md rule).
- Docs/help disclose that --settle with @ref on web shares click's existing
  limitation; use a selector or coordinates for the settled diff.

* test: drive hover through the apple output guard; cover direct hover dispatch

The provider-integration apple-leak guard partitions every public command
into driven/skipped; hover was neither, which failed Integration Tests and
took Coverage down with it. Drive it (it reaches the Apple capability
refusal, which is scanned like any other error response). Also cover the
direct-dispatch handleHoverCommand seam.

* test(web): drive hover @ref in the provider-backed web scenario

The integration-progress gate requires every public command to be referenced
by a provider-backed scenario. Add hover @ref to the web desktop flow: it
must reach the provider's hoverRef handle (never a coordinate) and be
recorded on the session without fabricated x/y, like click @ref.
2026-08-18 11:32:48 +02:00
Michał Pierzchała 62001cf210 refactor(record): derive session recording from the publication lifecycle (#1719)
* refactor(record): derive session recording from the publication lifecycle

`SessionState.recordSession` stored an answer the script-publication
aggregate already contained. Every writer set both, but nothing made them
agree, and #1533 was the consequence: a `--save-script` ingress re-armed
the flag behind an ABORTED status, and a bare `close` published a
recording the caller had been told was aborted.

That fix routed every write through one rule, which made the two agree
without making disagreement unrepresentable. The field remained a second
source of truth, and its doc comments had to carry the invariant that a
type could enforce.

Remove the field and derive the answer. `isRecordingPublication` reads
recording off the lifecycle: ordinary authoring records only while ARMED;
a repair transaction records for its whole lifetime, terminal statuses
included. That last clause is deliberately exact rather than merely safe —
`armRepairStep` armed the old flag and neither `abortRepair` nor
`commitRepair` ever cleared it, so narrowing it would silently stop
evidence capture for a committed repair. Whether it should is a real
question, and a behavior change, so it is left alone here.

What this buys, beyond one less field:

- `buildNextOpenSession` and `finalizeOrdinaryCloseScript` make no
  recording decision at all now, so no surface can arm recording without
  moving the lifecycle that authorizes it.
- The writer's publication gate is answered entirely by the aggregate. Its
  separate ABORTED check is gone: a terminal authoring lifecycle is
  already not recording, so one question replaces two that could disagree.
- The R7 ownership ratchet drops from 23 writer-owned fields / 29 owner
  claims to 22 / 26, and the layering manifest loses the entry whose
  comment documented the smell ("deliberately set on its own by paths that
  record without arming a publication").

Behavior-preserving: the derivation reproduces what the flag held at every
transition. The test fixtures that armed `recordSession` with no
publication state described a shape production stopped producing at #1478;
they now carry the lifecycle that causes recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* test(close-script): flush queued event-log writes before removing the tmp root

CI failed the Coverage lane with ENOTEMPTY removing the test's tmp root,
in `afterEach` rather than in an assertion.

`SessionStore.recordAction` QUEUES its event-log append
(`queueEventLogWrite`) instead of writing it, and every close path in this
file records an action. Nothing awaited that write, so `fs.rmSync(root,
{recursive: true})` could race it: the pending append recreates
`<root>/sessions/<name>/` while rmSync is walking, and the final rmdir
fails ENOTEMPTY. It needs CI's parallel load to lose the race — the file
passes 12/12 in isolation locally.

Await `flushSessionEventLogWrites()` before removing. The hazard is latent
in any test that records actions and then removes its tmp root; this fixes
the file that failed rather than sweeping the pattern, which deserves its
own change.

Not added to the #1419 contention-retry list: that list requires a
concrete spawn/wait mechanism named per entry, and this file has none. The
race was a real teardown bug, not lane contention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* docs: correct ADR 0016 on recording vs publication for repair

Review caught a real overstatement. The amendment claimed evidence capture
and publication authorization are "the same question asked of the same
state". That holds for ordinary authoring — ARMED both records and
publishes, ABORTED and PUBLISHED do neither — but not for repair:
`isRecordingPublication` is true for every repair status including
`committed` and `aborted`, while the writer additionally applies
`isRepairArmedWriteBlocked`, refusing a committed transaction and one that
is not yet committable.

State it as it is: both decisions derive from the same aggregate, but they
remain distinct predicates, and collapsing them would republish a committed
repair or commit an incomplete prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 10:58:03 +02:00
Michał Pierzchała 4f9aded0b8 fix(record): make an aborted authoring recording terminal by construction (#1712)
* fix(record): make an aborted authoring recording terminal by construction

A second successful `open` on an `open --save-script` session aborts the
recording: the aggregate goes to `authoring{aborted}`, `recordSession` is
cleared, and the caller is warned. `close --save-script` then refuses it with
"Retry with plain close; it will tear down the session without writing."

That promise was not kept. When the second `open` itself carried
`--save-script`, the recorder's shared flag ingress re-armed `recordSession`
while leaving the status terminal, and a bare `close` published the full
session log — the writer gated only on `recordSession` and the repair variant,
so nothing on the ordinary authoring path refused an aborted lifecycle.

The abort is now terminal by construction rather than inert by ordering:

- `isAuthoringAborted` gives the pure aggregate one home for the question.
- `applyRecordedSaveScriptFlags` takes no branch for an aborted lifecycle:
  it neither re-arms recording nor retargets the output path.
- `SessionScriptWriter` asks one `isPublicationWriteBlocked` question covering
  all three reasons to publish nothing, so every path reaching the writer
  (bare `close`, teardown, idle-reap, active publication) refuses it.

Armed recordings, published recordings, and every repair transaction are
unaffected; the control tests for those stay green against the pre-fix code
while the five new regressions go red.

Closes #1533

* docs: record the #1533 resolution in ADR 0016 and fix a stale symbol reference

The ADR 0016 close-time amendment still described #1533 as unresolved, and
the session-close.ts note named `isAuthoringAbortedWriteBlocked` — a private
helper that was folded into `isPublicationWriteBlocked` when the writer's
three sequential guards collapsed into one predicate, so the symbol names
nothing in the tree.

* fix(record): arm recording through the publication lifecycle, not around it

`recordSession` is an evidence-capture flag, but three surfaces set it
directly without consulting the publication aggregate, so it could
contradict a terminal ABORTED authoring status. The #1533 fix closed the
recorded-action ingress and made the writer refuse an ABORTED lifecycle,
then documented the remaining contradiction as acceptable — the writer's
own comment noted that "something can re-arm that boolean behind the
terminal status".

That something was live: `buildNextOpenSession` re-armed recording for any
`open --save-script`, and `applyOrdinaryScriptRecordingOpenOutcome` only
aborts a lifecycle that is still ARMED. A third `open --save-script` on an
already-ABORTED session therefore left `recordSession` true behind the
terminal status. The writer gate hid the publication symptom, but the
session kept paying recording-time costs for a recording that can never
publish: `recordSession` disables the direct iOS selector fast paths for
click and get, forcing every interaction onto the snapshot route.

Route the flag through one rule owned by the publication projection
(`recordSessionAfterSaveScriptFlag`), which answers "not recording" for an
ABORTED lifecycle on every surface that handles it — the re-open builder,
the close finalizer, and the recorded-action ingress. The writer's gate is
unchanged and still correct; it now stands on the aggregate alone rather
than as a net under a known drift, so the comments defending the drift are
replaced by statements of the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* docs: describe the full #1533 surface in the changelog entry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 18:01:21 +02:00
Michał Pierzchała 588a419524 feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake (#1678)
* feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake

MCP 2026-07-28 drops the initialize handshake: each request carries its
protocol version and client capabilities in `_meta`, and clients probe
`server/discover` to tell a modern server from a handshake-only one.
agent-device answered that probe with -32601, so a dual-era client fell back
to `initialize` and a modern-only client had no way to connect at all.

Serve both eras from the one stdio process, which is what the spec calls a
dual-era server:

- `server/discover` advertises the supported revisions, the tools
  capability, and server identity.
- A request declaring a protocol version in `_meta` is served modern: its
  result carries `resultType: "complete"` and
  `_meta["io.modelcontextprotocol/serverInfo"]`.
- `tools/list` and `server/discover` return `ttlMs`/`cacheScope`, so clients
  can cache the 55-tool ~223KB list instead of re-fetching it every start.
  The list was already emitted sorted, which is the other half of what makes
  it cacheable.
- A declared revision we do not implement is rejected with
  `UnsupportedProtocolVersionError` (-32022) naming the ones we do.

Also fixes legacy version negotiation, which the era split surfaced:
`initialize` returned 2025-11-25 whatever the client asked for, so a client
pinned to 2025-06-18 was answered with a revision it had not requested — the
lifecycle contract's cue to disconnect. It now echoes the requested revision
when we implement it, and otherwise names the newest legacy one we do.

Legacy responses are otherwise byte-identical: `initialize` and `ping` are
still served, and no cache, `resultType`, or `_meta` field is added to them.
The stdio transport, the tool set, and every tool's schema are untouched, so
the CLI, Node, and daemon surfaces are unaffected.

Era handling lives in its own module so the router stays a dispatcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz

* fix(mcp): keep 2025 revisions on the legacy wire contract and require modern metadata

Review found the era model was too loose in two ways.

Membership was one flat set, so a request declaring 2025-11-25 or
2025-06-18 through modern `_meta` was served the 2026-only envelope
(`resultType`, `serverInfo`, cache hints) — fields absent from those
revisions' schemas — and `initialize` would echo 2026-07-28, agreeing to a
revision whose handshake the modern era removed. Split modern and legacy
membership so the declared revision picks the wire contract: 2025 declared
through modern framing is answered legacy-shaped, `server/discover` requires
a modern revision because it exists in no legacy one, and `initialize`
negotiates only within the legacy set.

Modern request metadata is now required rather than guessed. `_meta`
carries `protocolVersion` and `clientCapabilities` as required fields, so
`server/discover` without them is malformed instead of being promoted to
modern, and a half-declared `_meta` is rejected as invalid params (-32602)
rather than having its lenient handling locked in by tests.

Adds black-box router cases for declared-2025 requests, initialize(2026),
`server/discover` with missing and with legacy metadata, and a declared
revision without client capabilities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz

* fix(mcp): validate supplied client identity and gate the methods 2026-07-28 removed

Two protocol-boundary gaps from review.

`clientInfo` was read but never checked. The field is optional in 2026-07-28,
so its absence is fine, but a supplied one must be an `Implementation` —
`clientInfo: 42` was accepted and the request served. A present value now has
to carry string `name` and `version`, matching how `clientCapabilities` is
already validated; omitting it stays legal.

`initialize` and `ping` were served regardless of era, so a request carrying
valid modern `_meta` could call methods its own revision deleted and get a
`resultType: "complete"` envelope back — with `initialize` reporting a legacy
`protocolVersion` inside a modern result. Both are now gated by the resolved
era and answer -32601 to modern-framed callers, while metadata-free legacy
calls keep working unchanged.

Adds black-box router cases for modern-framed `initialize`/`ping` (each
paired with its still-working legacy call) and for malformed `clientInfo`,
including the omitted-is-legal case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz

* fix(mcp): validate every recognized clientInfo field, not just the required ones

The previous round checked `name` and `version` but let malformed recognized
optional fields through, so `{name:'c', version:'1', websiteUrl:42}` — and the
same for `title`, `description`, and `icons` — was accepted and served.

`Implementation` validation now type-checks each recognized field when
present: `title`, `description`, and `websiteUrl` as strings, and `icons` as
an array of `Icon`, where `src` is required and `mimeType`, `sizes`, and
`theme` are typed when supplied (`theme` against its `light`/`dark` union).
Unrecognized keys still pass — `_meta` payloads carry extension fields, and
rejecting those would reject the future.

Adds regressions across both layers: a wrong scalar per optional field, a
wrong icons container, an icon entry missing `src`, and each malformed typed
icon member. The positive cases pin the other direction — a fully populated
clientInfo carrying an extension key must still be served, so the validator
cannot harden into rejecting what the spec allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz

* fix(mcp): accept schema-valid empty strings in clientInfo identity fields

`isImplementation` reused `stringField`, which requires a non-empty string, for
the required `name` and `version`. The 2026-07-28 schema declares both as plain
`string` with no minimum length, so `{name: "", version: ""}` is a conforming
`Implementation` and was being answered -32602. Required now means present and
a string.

`Icon.src` gets the same treatment. Its `format: uri` annotation is not
something this server enforces — any other non-URI string is accepted — so
rejecting the empty one alone was arbitrary rather than stricter.

Adds positive regressions at both layers for empty `name`/`version` and an
empty `Icon.src`, alongside the existing malformed cases, so the validator is
pinned against over-rejection as well as under-rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 12:48:38 +02:00
Michał Pierzchała 6c0fcb64a1 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>
2026-08-08 08:57:32 +02:00