mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
main
65 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
b7c82ea152 |
fix(test): preserve colliding diagnostic artifacts (#2507)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
c08545897c |
fix(test): expand relative globs from the literal working directory (#2490)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
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. |
||
|
|
3d503a0e88 |
fix(test): emit well-formed JUnit XML for replay results (#2477)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
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. |
||
|
|
72ccf1a476 |
fix(maestro): use canonical deep-link classification for exports (#2463)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
ee116079ba |
feat(maestro): export replay flows that switch apps (#2407)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
74a70f1764 | refactor: remove next-major compatibility surfaces (#2046) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
af96c6608d | feat(ios): publish effective snapshot geometry (#1931) | ||
|
|
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 |
||
|
|
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). |
||
|
|
ce71e96912 |
fix(android): warn when recording readiness inspection fails open (#1898)
* fix: disclose Android recording readiness inspection failures * test: prove Android readiness warning bounds |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |