Commit Graph

1914 Commits

Author SHA1 Message Date
Michał Pierzchała edff9074af fix(daemon): scope the fingerprint bypass to two installed trees
An installed client declined the code comparison on the strength of its own tree
alone, which left whatever is already running unverified: a source checkout that
shares an explicit --state-dir can publish any code it likes under a published
version string, and the installed client would have run it (#2458 review).

A daemon now records which tree it started from beside its signature, and the
bypass is pairwise — it holds only where both sides say they are installs of that
version. A checkout still judges a checkout by signature; a pair whose trees
cannot match at all restarts the daemon rather than run code it cannot identify,
which is also the rule a daemon too old to report an origin is judged by.

Closes #2458
2026-09-14 13:22:27 +02:00
Michał Pierzchała 5fef822379 fix(daemon): let an installed version stand as its whole code identity
Two installs of one published version signed their identical bytes
differently, because the fingerprint is size:mtime and an installer stamps a
fresh mtime on every file. The second client therefore read "code-signature
mismatch" and replaced a daemon that already ran exactly its code, dropping
the live session on it (#2458).

An installed tree's version already fixes its bytes, so it now answers with
no fingerprint and the version alone decides reuse. A source checkout, where
code does move under an unchanged version, keeps the check. The predicate
that tells the two trees apart moves beside the project-root walk it shares,
and the takeover ladder moves beside the answer it consumes so the order
lives in one place.

The packaging gate proves the premise against a clean install: the published
tree ships no src/daemon.ts.

Closes #2458
2026-09-13 20:32:49 +02:00
Michał Pierzchała ab3d11e069 test(layering): classify daemon edges that reach platform mechanics through a hub (#2557)
R76 keyed its inventory on the target filename, so a daemon import of a root module that
imports the platform-runtime family itself was invisible: the daemon could gain or widen an
edge to a root hub without any gate noticing. Dynamic edges were invisible in the same way,
and the ranked spine (R4, R5, R6) cannot see a dynamic import's direction at all.

The target predicate is now computed from the tree: the platform-runtime family plus every
module outside the daemon zone that reaches it, over static and dynamic edges alike. That
made exactly three real edges visible, and all three are classified rather than allowlisted:
the two provider-runtime hubs the daemon runtime composes, and the dynamic interactor lookup
in the snapshot capture, which is a leak and now carries the rationale and the deepening
issue (#2555) that a filename pattern never would have asked for.

Part of #2542
2026-09-13 17:00:28 +02:00
Michał Pierzchała b790279bf1 refactor(runtime): own provider-device admission behind a typed capability (#2556)
Ten daemon files imported isActiveProviderDevice from src/provider-device-runtime.ts,
so the daemon read provider runtime ownership mechanics directly from twelve sites
(ten daemon, one daemon runtime composition, one src/core).

The daemon now consumes a named capability: src/daemon/provider-device-admission.ts
declares ProviderDeviceAdmission with the one fact the daemon decides on, defaults to
the no-provider state every un-composed process already sees, and is installed by root
composition where the provider request providers are already composed. The ten leaf
call sites change only their import specifier; the predicate keeps its name, its
per-call read, and the request-scoped ALS behaviour underneath it.

src/core/interactors.ts keeps its edge: it also needs getProviderDeviceInteractor and
sits below the daemon, so it cannot consume the daemon's seam.

Part of #2541
2026-09-13 17:00:27 +02:00
Michał Pierzchała 4e8f367321 refactor(daemon): bind only the command families the daemon dispatches through (#2548)
The daemon built its in-process AgentDevice with src/runtime.ts, which value-imports
the all-family command barrel. Five daemon files therefore evaluated the management,
recording, observability and system families they never call, and every change under
src/commands became a daemon-restart-cost event.

Split the runtime assembly (backend, artifacts, sessions, policy, cancellation) into
src/runtime-factory.ts, which carries no command surface, and add
src/runtime-command-surface.ts for the three families an in-process executor
dispatches through: capture, selectors, interactions. src/runtime.ts keeps its public
shape and now composes both, so createAgentDevice stays the one full-surface path and
bindCaptureCommands is the single capture-family binding definition.

Daemon value closure: 717 -> 705 files, 121 -> 108 src/commands files, 19,659 ->
17,434 commands LOC. The residue is the dispatch surface plus the two
cli-schema/command-schema.ts edges owned by #2543, which take it to 34.

Part of #2540. The issue's completion condition (zero
`src/commands/**` files in the daemon value closure) is not reachable while the daemon executes
commands in-process: the achievable floor is 34 files, so the condition needs a maintainer
decision before this can close the issue.
2026-09-13 17:00:27 +02:00
Michał Pierzchała 3394d5b89c fix(android): keep a scroll's swipe out of the IME window (#2514)
* fix(android): keep a scroll's swipe out of the IME window

Android is the case the clip exists for beyond iOS: an `adjustPan` or `adjustNothing` activity keeps a
window whose recorded bounds already run under the IME, so a plan built from them aims at the keys.
The helper now reports the largest input method window beside the application window, in absolute
screen pixels like the window next to it, and `scroll` clips its band with the shared rule or refuses
when the keyboard owns the surface.

An older helper reports no keyboard keys, and a provider-supplied viewport has no IME channel at all.
Both read as "nothing to avoid", which is what the shared rule already does with a missing frame;
neither turns into a refusal.

`UiAutomation.getWindows()` answers with an empty list until the service asks for interactive windows,
so the read applies the seam the tree capture already uses rather than depending on a snapshot capture
having run first in the same instrumentation; the one-shot fallback below it has no such neighbour.
Measured on a Pixel 7 emulator with an `adjust=pan` contact editor, the application window keeps its
full 2400px height while the IME window reports `[0,1517][1080,2400]`, and `scroll down` answers with
`referenceHeight: 1505`, `keyboardMinY: 1517`, `keyboardAvoided: true` and a swipe ending at 301
instead of starting at 1920 under the keys.

* fix(android): clear the composer, not just the key plane, before a scroll swipes

The helper kept the largest `TYPE_INPUT_METHOD` rectangle as the keyboard. A composer bar and its key
plane can arrive as separate windows and the key plane is the larger one, so the earlier top edge was
discarded and the clipped band still ended inside the composer: the swipe landed on keys the rule
exists to keep it off.

The read now copies every input method window and unions the ones the swipe's centre line crosses,
which is the same line the shared clip rule tests. A candidate strip at the edge of the screen that the
swipe can never reach no longer shortens the band either. The selection runs on plain window edges,
because `Rect` is a device type whose constructors throw off-device, so the two-window case is a unit
test rather than a simulator-only path.

* refactor(android): pass the measured occlusion explicitly and drop the duplicate rect check

The clip rule already fails open on a keyboard frame it cannot measure,
so the helper reader no longer needs its own copy of the check, and the
gesture-viewport validator goes back to its original body. The refusal
names its three numbers instead of spreading the clip variant, so the
discriminator never leaks into error details.
2026-09-13 13:55:38 +02:00
Michał Pierzchała ab0c7a4328 fix(scroll): keep the swipe above the keyboard, refuse when it cannot (#2503)
* fix(ios): clip a scroll's swipe above the keyboard, refuse when it cannot

The runner owns the live keyboard frame, so it does the clip and reports what it left: a scroll
answers with `keyboardAvoided` and `keyboardMinY` beside its plan, and refuses with
`SCROLL_KEYBOARD_OCCLUDES_SURFACE` when the keys leave too little band to swipe in instead of flinging
into them. It never dismisses the keyboard, which would drop focus and mutate state that
session-action provenance does not record.

Scroll's keyboard policy moves to `requiredWhenAvailable`. The probe costs a live AX fetch, but
gating it on a healthy tree left the first scroll of a session swiping under the keys, which is the
failure this is for. Every scroll logs its decision, including the two ways it avoids reading the
keyboard at all.

Scroll no longer shares `frameAvoidingKeyboard`, whose 25% fail-open was a tap-reference-frame rule;
that path is unchanged for its remaining callers.

* chore(gates): run the scroll viewport policy tests on the iOS lane

The parity table only detects drift if both halves run in CI. Two of these three were reachable by no
lane, so the Swift half of the table was a local assertion.

* fix(ios): keep the keyboard clip out of the scroll's rotation basis

`resolvedScrollViewport` handed the command one frame for both jobs, and the coordinate rotation reads
a frame's HEIGHT to map a `landscapeRight` native x. Clipping an 834pt landscape viewport to 576pt
therefore moved the dispatched gesture 258pt sideways off the lane the plan had just been built for:
the clip fixed the keyboard and broke the gesture.

The resolved viewport now names both frames, and the gesture comes from one dispatch decision, so the
band the plan is planned inside and the frame its coordinates rotate against cannot be swapped. The
landscape case asserts through that decision and fails on the swap.

* fix(ios): report a scroll's clipped band in its response
2026-09-13 13:55:37 +02:00
Michał Pierzchała 5cf6414fd2 fix(contracts): state the scroll keyboard clip once for every platform (#2537)
* fix(contracts): state the scroll keyboard clip once for every platform

* fix(apple): surface the scroll keyboard clip as evidence and a typed reason

* refactor(contracts): state the scroll keyboard refusal details once and keep the runner's message

The Apple scroll owner rebuilt the refusal per command, discarding the
runner's measured message and carrying an unmeasured variant of the
error builder for it. The shared reason and hint are now one frozen
object in scroll-gesture; the Apple owner adds it to the runner's own
error (matched on the typed runner code, transport details kept), and
the error builder takes a plain measured occlusion, which only Android
produces in-process. The help text names the behaviour in one clause;
the hint carries the recovery at the moment it matters.
2026-09-13 13:55:37 +02:00
Michał Pierzchała 7a25a02f6d fix(record): replay the finished export from a retried record stop (#2534)
* fix(record): replay the finished export from a retried record stop

A remote record stop can outlive its client window while the daemon is still exporting. The finished manifest was then read as no active recording, and its metadata carried no client output path, so the caller had no way to collect the file. A repeated record stop now serves the completed export and says so in the timeout hint.

* refactor(record): declare each completion codec once

A mapped codec per completion property drives encoding and decoding from one declaration, and the declaration fails to typecheck if a property has no codec.

* fix(record): keep manifest encoding inside the session resource module

Session teardown reaches the recording resource definition while it loads, and that eager closure takes no new module. Writing a completion is property reads only, so the field map and writers now live with the resource definition; reading one back needs the recording vocabulary and stays behind the stop path.

* test(client): give the request timeout hint its own mirror file

The hint assertions had outgrown the aggregate client test past its size ratchet; they mirror src/daemon-client/daemon-client-timeout.ts, so they move rather than shrink.

* refactor(record): store the finished stop response under one manifest key

The manifest now holds the completion as the one object record stop returned, so a replay cannot lose a field between an encoder and a decoder, and the reader lives with the stop path that needs it. Recovery still refuses a response whose served path or caller-side paths are not whole.

* refactor(record): reuse the scope guard and record path their owners declare

A stored scope is checked by isRecordingScope next to the vocabulary it validates, and a session's durable record path comes from the factory that names it instead of being re-derived at each read.

* refactor(client): hand the timed-out request to its timeout handler

Command, session, and action all come from the same request, so they are passed as one request instead of three more positional arguments.

* refactor(client): name the timed-out request fields the handler reads

The client timeout handler stays off the daemon request shape: R10 daemon-modularity holds external importers of that module at the merge-base count, so the fields arrive as named properties instead of the request object.

* refactor(client): read a timed-out request's fields once for both transports

A socket timeout and an HTTP timeout described the same request with two copies of the same mapping.
2026-09-13 13:54:19 +02:00
Michał Pierzchała b2b084d2e1 docs: fix phantom specifiers, the duplicate ADR 0019, and the Node floor (#2533)
AGENTS.md routed request cancellation/progress and diagnostics to
`@agent-device/capture-kit` subpaths that no package exports; both live in
`@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also
named `@agent-device/contracts` as an importable seam although that package
publishes no root export, and claimed `src/daemon/handlers/session.ts` was over
budget after that extraction already landed at 242 lines.

Two ADRs carried number 0019. The hop trace has its own claims to make, so it now
numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022.

The Node floor split was undocumented: `engines.node` stays at 22.12 because CI
installs the published tarball on that floor, while contributors need 22.13 for the
pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and
the web backend's Node 24 requirement.

Extend the agent-guidance contract to resolve every `@agent-device/*` specifier
AGENTS.md names against the owning package's `exports`, root included, so neither a
phantom subpath nor a phantom package root can route an agent to a module that does
not exist.
2026-09-13 10:15:57 +02:00
Michał Pierzchała e6f288012b fix(daemon): let admitted work keep the lease it is working on (#2517)
* fix(daemon): let admitted work keep the lease it is working on

A lease renewed only at admission, so a command slower than its own inactivity TTL
expired the lease that was paying for the device it was using. Expiry then tore the
provider session down underneath a client still waiting for that same command's
result, and every later command on the session reported a lease that was no longer
active. The session's own work was the thing that killed it.

Admitted work now preserves its lease the way a human-control hold does: while the
request is still wanted it defers expiry, and finishing while still wanted renews
the lease for its existing TTL from the moment the work ended. Work whose client
hung up preserves nothing — it neither defers expiry past that cancellation nor
renews the lease when it finally lands — so a handler that ignores its cancellation
cannot hold a rented device open.

Found while investigating #2509. Not its reported mechanism: a cloud WebDriver
connection profile asks for a ten-minute lease, so a one-minute hang cannot starve
it. This reaches leases on the daemon's one-minute default.

* fix(daemon): drop a released lease's work claims instead of leaving them empty

A completed pass emptied its set but left the key behind, and only the expiry sweep
removed keys — which never reads a released lease again. Every connect-and-close that
ran a command on a leased device left another permanent entry in the daemon.

Releasing the last pass now removes its lease's entry, and releasing the lease drops
its claims outright and disarms the passes still running on them, so work that
outlives its own lease renews nothing.

* docs(daemon): state the lease invariant without borrowing #2509's cause

Four comments told the report's story as though it were this mechanism. A cloud
WebDriver connection profile asks for a ten-minute lease, so the reported one-minute
hang cannot have expired anything. The invariant stands on its own; where it came from
and which leases it reaches belong in ADR 0007 and the commit, not in each test.
2026-09-13 09:23:52 +02:00
Michał Pierzchała b92b6ca70c fix(cloud): cancel a screen read that its caller gave up on (#2516)
* fix(cloud): cancel a screen read that its caller gave up on (#2509)

A hosted page-source read that outran its request was dropped by the client while
the driver kept walking the UI tree. Nothing at the wire said the answer was no
longer wanted, so the read stayed in flight and held the session it was blocking.
On a screen that never goes still -- a looping video, a live ticker, continuous
animation -- every later command then queued behind a capture nobody was waiting
for, which is what makes one stuck `snapshot -i` look like a frozen session.

Bind the read to the request that asked for it, on both Android and iOS, and say
what a source read that runs out of budget was waiting for. The timeout keeps its
`webdriver_request_timeout` reason and gains a hint naming a screen that never goes
still, so the rented minutes end with a cause rather than a silent hang.

Closes #2509

* fix(cloud): stop advising a timeout the source read never sees

The hint and the AWS docs both told the caller to retry with a larger `--timeout`.
That flag widens the command envelope around the read; the read's own budget is the
transport's, so the advice could not work and cost rented minutes to discover. The
report on #2509 shows exactly that experiment failing at 65 seconds.

Say whose budget it is, and offer the two things that do work: a `screenshot`, which
never reads the tree, and the `@refs` an earlier snapshot captured.

* test(cloud): claim only what the cancellation layer proves

The scenario comment credited this layer with the lease-gone symptom, which a
ten-minute cloud WebDriver lease rules out for the reported run. And one assertion
message said the driver's own tree walk had been cancelled, when what the test
observes is our request being hung up at the wire.

Behaviour and coverage are unchanged; the test now says what it measures.

* docs(snapshot): put the never-idle screen rule where snapshot advice lives

The read that fails is shared hosted WebDriver behaviour, so it belongs on the
Snapshots page rather than only under one provider. The provider page keeps the part
that is about being metered.

Both pages name the two dead ends, since both were tried on the reported run: a
larger `--timeout`, which widens the command around the read, and `settings
animations`, which hosted WebDriver sessions do not implement.

* docs(snapshot): bound the cancellation to the waiting this side controls

Hanging up the client read proves agent-device stops waiting and stops holding the
session. It says nothing about the provider, whose tree walk can keep running and can
still occupy that session's queue server-side. The paragraph claimed the recovery the
fixture does not prove.
2026-09-13 09:23:52 +02:00
Michał Pierzchała 973b74cc14 chore(config): scope the qs override and drop dead fallow, worktree, prettier entries (#2535)
`qs: 6.15.2` overrode every major, pinning the exact version behind
GHSA-x5fp-wj9c-mxmx and GHSA-4mjr-xmp4-gh2g; `qs@6: ^6.16.0` keeps the scoped
major and clears both advisories in the mutation tooling tree. `undici@7` now
matches the exact 7.29.0 pin in package.json — tsdown bundles undici into the
published build, so a caret here let lockFileMaintenance move the shipped bytes
under an unchanged pin. The lockfile recorded that: both undici importers
disagreeing with their own package.json, now agreeing.

`.fallowrc.json` listed two entry roots deleted in 26ac865c63 and 8889a17cf1,
which Fallow drops silently, and `.worktreeinclude` listed an
android/multitouch-helper tree that no longer exists. `.prettierignore`
duplicated .oxfmtrc.json's ignore list with nothing left reading it.
2026-09-13 07:57:50 +02:00
Michał Pierzchała b8d70aa8a1 fix(daemon): keep a discarded rejection from shutting the daemon down (#2532)
An aborted Apple simulator recording start discarded the transport promise:
`void started.then(rollback)` has no rejection handler, and the local transport
rejects with the abort reason after its dynamic imports settle. The rejection
reached the daemon's only unhandledRejection handler, which exits with code 1
and kills every open session.

End the rollback chain in a handler at both abandon sites so a rejected
acquisition, or a rollback that itself fails, cannot escape.

Sweep the remaining single-handler `void … .then(` sites: a rejected renewal now
fences as unconfirmed authority instead of settling as a normal renewal, which
would re-arm renewal work with no backoff, and the Limrun drain drops its
discarded continuation by deleting from `pending` when the deferred settles.
2026-09-13 07:56:15 +02:00
Michał Pierzchała 8e8eeb2ced refactor(snapshot): drop the bridge truncation dimension; stop promising --scope for depth caps (#2511)
The dimension and limit the bridge adapter inferred for a cut capture had
no renderer and one consumer, the comparison-identity string, where the
kind alone gives the same comparability. The runner never produced them.

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

The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android
helper's bound. Measured on a synthetic 600-row screen, acquisition time did
not change with the cap while the 1500 cut dropped the on-screen footer.
2026-09-12 20:52:48 +02:00
Michał Pierzchała 9aa6465768 perf(scripts): add a device-free PNG crop benchmark (#2505)
* perf(scripts): add a device-free PNG crop benchmark

`pnpm bench:png-crop` runs the whole-image pipeline and the shipped region crop
over the same bytes in one process, so the comparison holds the capture content,
the deflate stream, and the machine fixed. The corpus is generated, which keeps a
run at seconds with no device; real captures join the same table via `--file`, and
each corpus entry prints its compressed size so an unrealistic corpus is visible.

The README records what the measurements said, including the case the encoder
policy loses: `None` on every scanline is faster everywhere but writes about 1.7x
more bytes than a filtered encoding on smooth low-frequency content.

* chore(gates): run the PNG crop benchmark's model tests in unit-core

Registers scripts/png-crop-benchmark/*.test.ts so the timing summary that the
report is built from stays covered without a device lane.
2026-09-12 18:23:28 +00:00
Michał Pierzchała 076234e2eb perf(screenshot): read the crop region instead of decoding the whole capture (#2504)
`screenshot --crop-on` paid for a full PNG decode and an RGBA re-encode of the
capture before keeping a frame. One worker job now turns the captured bytes into
the cropped bytes: a region reader that reconstructs pixels only down to the
box's last row and allocates only the box's pixels, and a truecolor writer that
drops the alpha channel when the cropped pixels carry none.

The reader claims the 8-bit non-interlaced truecolor layout that iOS simulator
and Android emulator captures arrive in, and only for a file it can vouch for:
the IHDR and every chunk checksum are verified, an unrecognised critical chunk
name is a decline, and every row's filter byte is read whether or not the box
reaches that row. Everything else — palette, grayscale, interlaced, 16-bit, a
checksum that does not match — falls through to the general PNG reader, which
keeps owning the canonical decode error and the previous RGBA output. A box
covering the whole image reads through that general reader too, so an unchanged
answer is only reported for a file that reader accepts.

Cropped bytes verify pixel-for-pixel against ImageMagick's own crop across RGB,
RGBA, grayscale, palette, 16-bit, interlaced, and translucent sources, on both
iOS simulator and Android emulator captures.
2026-09-12 18:23:28 +00:00
Michał Pierzchała 37d67de776 fix(android): carry accessibility selected state into snapshots (#2515)
The snapshot helper never serialized `selected`, and the host reads only the
helper's XML, so no later layer could recover it: `get attrs` had no `selected`
field, no snapshot node was marked selected, `is selected` could not match, and
a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible
condition did not match" for a visible element while `selected: false` matched
every Android node (#2462).

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

Snapshot lines render that marker on the default formatter path too: `--settle`
and `diff` already compared selection, and a line that weighs a fact it cannot
print turns a tab tap into a changed pair whose two lines look identical.
2026-09-12 20:18:35 +02:00
Dennis Khylkouski 49fbaf61b8 fix(aws): defer Android app launch until open (#2512) 2026-09-12 19:20:18 +02:00
Ahmad Al-Faqih b7c82ea152 fix(test): preserve colliding diagnostic artifacts (#2507)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-12 18:22:50 +02:00
Michał Pierzchała cda7522095 fix(ios): gate alert activation on a fresh hittable read (#2506)
* fix(ios): gate alert activation on a fresh hittable read

A snapshot can surface an alert button before the owning app has made it
hittable, and a starved host widens that window. The single, never-repeated
activation tapped into that gap, dropping the button press, riding an
unchanged alert to ALERT_DEADLINE_EXCEEDED with First actions: 0, and
flaking the alert-replacement runner regressions under CI contention.

Wait for a fresh exists+isHittable read before the one activation; still
activates at most once.

* fix(ios): recheck the deadline after the alert hittable probe

The hittable read is a synchronous query that a starved host can complete
past the command deadline. It previously handed back true unconditionally,
so handleAlert tapped once more after the budget was already gone. Only a
read that lands before the deadline buys back the single activation.

Route the read through a unit-test-overridable probe and add a regression
that completes the probe past the deadline and asserts, via the fixture's
own action counter, that no button is activated.

* chore(gates): select the late-hittable-probe alert regression
2026-09-12 18:22:21 +02:00
Ahmad Al-Faqih 5643107442 fix(diff): include maximum RGB distance at threshold one (#2508)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-12 18:21:50 +02:00
Michał Pierzchała eefe37b51e docs(adr-0011): narrow the offscreen rescue comment to the per-request surface policy (#2465)
* docs(adr-0011): note the offscreen live rescue reads the tree's surface

The offscreen guarantee's live rescue runs the runner's direct querySelector, which consumes the single activeApp that prepareActiveCommandContext resolved for the snapshot tree as well, including an in-place system surface. Record that the tree and the rescue read the same surface so the cell's rationale stays accurate.

Closes #2452

* docs(adr-0011): narrow the offscreen rescue comment to the per-request surface policy

The snapshot capture and the live rescue are separate runner requests, and each
calls prepareActiveCommandContext on its own, so only the selection policy is
shared, not the surface instant. State that, and say plainly that a surface
appearing or dismissing between the two requests is not detected.

Closes #2452

* docs(adr-0011): limit the shared surface seam to runner-routed captures

An eligible iOS simulator snapshot is served by the host AX bridge
(packages/platform-apple/src/snapshot-route.ts), which never reaches the
runner's prepareActiveCommandContext. The rescue's direct querySelector always
does, so the two requests share that surface policy only when the capture is
runner-routed too - which is the case #2448 forces for the system surface.
Keep the unchanged statement that no surface identity crosses the two requests.
2026-09-12 07:54:50 +02:00
Michał Pierzchała 3cd6341388 fix(scroll): pace edge passes to rest and stop a stuck surface (#2499)
* fix(scroll): pace edge passes to rest and stop a stuck surface

A rubber-band bounce keeps shifting the surface after a fling, so the next
edge pass captured a phantom new offset, stacked another fling on top of the
bounce, and a single stuck container could fling 40 times without net progress.

`runScrollEdgePasses` now waits for the scoped scroll surface to hold between
passes before deciding or flinging again, and stops with `scroll_edge_no_progress`
when a container still reports hidden content but its descendants never shift.
The end pass-limit keeps its own `scroll_edge_pass_limit` reason. `scroll --until`
shares the same stuck-signature window and raises `scroll_until_no_progress`.

The stuck-window bookkeeping and surface fingerprint are shared from capture-kit
so both loops read one definition of "stuck" rather than a copy.

* fix(scroll): count recycled-cell and fresh-signature progress as movement

Two false stops survived the first cut:

- The surface signature coalesced to `identifier ?? label ?? value`, so a recycled
  cell that keeps its identifier and slot while its text changes looked unchanged
  and a genuinely advancing list was reported stuck. The signature now keys on
  identifier, label, and value together.
- `scrollSurfaceIsStuck` accepted any four captures with at most two distinct
  signatures, including `A,A,A,B`, and both loops stopped on the pass that finally
  advanced. It now also requires the newest signature to be one the window already
  showed, so rubber-banding trips but a fresh signature continues.
2026-09-12 07:45:42 +02:00
Michał Pierzchała de8703b6a0 fix(selectors): resolve a wrapper chain's control for uniqueness reads (#2501)
A control reported through its own accessibility wrapper answers a selector
twice, and a regular iOS snapshot omits unverified hittability, so the ladder
that relates a wrapper to its control cannot fire. #2482 collapsed that chain for
mutating resolution only: `press` tapped the toolbar button while `is visible`
and `get attrs` reported "Selector did not match" and `screenshot --crop-on`
refused the same screen as two nodes.

Export the collapse beside the classification that asks for it and apply it where
a read row's answer was a refusal. Rows that resolve before any refusal are
untouched, and a candidate set the rule does not recognize as one control - a
cell and the button inside it, or matches in distinct subtrees - still refuses.

Replay verifies a recorded target by resolving its recorded selector again under
the same row's refusal rules, so a step whose screen had not changed verified as
an identity mismatch on its first replay. Verification names the collapsed control
too, which is the node dispatch acted on and the node the recorded identity
carries.
2026-09-12 07:44:44 +02:00
Michał Pierzchała 93a9b146d4 docs(website): swap in the new agent-device logotype and README banner (#2502) 2026-09-11 18:58:24 +02:00
Michał Pierzchała d80fb35ec3 fix(browserstack): carry the full provider-allocation config over the lease_allocate envelope (#2494) (#2495)
* fix(browserstack): forward provider session metadata over the lease envelope

`--provider-project`, `--provider-build`, and `--provider-session-name` are
stored in the connection profile and reach the daemon on the line transport
(which forwards the whole request), but the compact JSON-RPC lease envelope
carried only `providerApp`. Over HTTP/remote daemons the daemon's lease-lifecycle
provider therefore saw no session-naming metadata and created BrowserStack
sessions as "Untitled Project" / "Untitled Build" with an empty name.

Read the four provider session-metadata flags through one shared projection
(`readLeaseAllocateProviderMetadata`) used by both the client's
`buildHttpRpcPayload` and the daemon's `toLeaseDaemonRequest`, so the transports
agree and the producer and consumer cannot drop a sibling again.

Closes #2494

* chore(gates): pin lease_allocate wire digests for the forwarded provider metadata (#2494)

* test(daemon-http): move the lease provider-metadata check into its own file

The provider-scenario daemon-http-server test is over the 1000-line tripwire
and may not grow; the lease_allocate metadata assertion now lives in
daemon-http-lease-allocate.test.ts, mirroring the lease projection in
http-server.ts.

* fix(browserstack): carry the full provider-allocation config over the lease envelope

The lease envelope named only the session-label fields, so a fresh remote
allocation still failed in prepareSession before the names could take effect:
device selection (platform, device), providerOsVersion, and the configured
device-feature/AWS knobs were dropped on the HTTP transport, while the line
transport forwards the whole request for free.

readLeaseAllocateProviderMetadata becomes readLeaseAllocateProviderFlags and
projects the full set the lease-lifecycle provider reads, pinned exhaustive
against CloudProviderProfileFields so a new field cannot silently miss it. The
producer and consumer already share this reader, so both transports agree.

Covered end to end in cloud-webdriver-lease-http.test.ts: the exact client
envelope is driven through a real daemon HTTP server into the real BrowserStack
prepareSession, asserting the capabilities that reach the hub.

* chore(gates): re-pin lease_allocate wire digests for the broadened projection (#2494)
2026-09-11 18:21:37 +02:00
Ahmad Al-Faqih fd80c1ee18 fix(ios): recover simulator recorder startup failures (#2447)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 17:47:11 +02:00
Michał Pierzchała 0feb4e26a0 feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) (#2448)
* 0.21.1

* feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438)

iOS apps that sign in via ASWebAuthenticationSession present the identity
provider in com.apple.SafariViewService, out of the app's process. Two facts,
both verified live on the iOS 26.2 Simulator, made these flows unautomatable:
activating or launching the host cancels the auth session, and the host AX
bridge cannot see the sheet because the app stays the AX primaryApp.

Serve and drive the sheet in place. A closed registry names the host (shared by
the TypeScript and Swift sides under a parity test); the runner reads and drives
it without activation and never adopts it as the session target; and the
Simulator route detects a running host with a cheap device-scoped ps probe and
takes the runner path, since the bridge would serve the occluded app tree as if
healthy. open refuses to launch a registered host, and captures carry a
system-surface disclosure.

Presence is foreground state, not tree content: a torn-down host serves a richer
tree than a live one, so content heuristics cannot tell them apart. The
never-activate guard is what keeps the foreground predicate sound, which also
makes the stale-tree failure mode unrepresentable for this flow.

Closes #2438

* chore(gates): register contracts/ios-system-surface in the export snapshot

* fix(ios): close the system-surface correctness gaps from review

Presence probe: absence and probe failure are no longer reported as "no
surface". The probe returns present/absent/unknown and the route takes the
runner for anything but a proven absent, so a sheet opened between two captures,
or a probe that cannot answer, can no longer fall through to a bridge capture
that would answer confidently from the occluded app tree. Only a positive
observation is memoized. The probe now matches with pgrep and reads only a
matched pid's environment, which is ~3x cheaper than the previous full
process-environment dump and stops copying every process's environment.

Open guard: the refusal moved to every resolved-host launch and terminate, so
the URL, deep-link and launch-args branches that returned before the old check
can no longer launch the host. Terminating a host is refused too, since that
cancels the presented session just as launching it does.

Comparison: the surface identity now reaches SnapshotState, and tap-failure
corroboration refuses outright when a baseline and a post-action capture
disagree about it, instead of letting app and sheet captures meet in legacy
same-presentation matching. Selector routes disclose an iOS system surface
through the shared disclosure seam rather than reading only the Android field.

The contracts import in the launch path is deferred so the app-lifecycle
facade's eager closure stays flat, and the runner's comment prose is trimmed
because apple/runner ships to npm as uncompiled source.

* fix(ios): route the system-surface probe through the Apple tool provider

The probe shelled out with runCmd, so every eligible capture spawned a real
process even in provider-backed tests that stub the Apple tool seam — 17 real
spawns in one scenario file, which is both wasted work and added latency on
timing-sensitive settle paths. It now goes through runAppleToolCommand like the
sibling ps probe, so a stubbed provider answers instead of spawning.

* fix(ios): disclose a skipped bridge when the surface probe cannot answer

Routing an unprovable probe to the runner is right, but the early return also
skipped runFallback, so the response lost its warning and kept an identity that
could still be compared against a bridge publication. An unknown probe now falls
back through the same disclosed path as a bridge failure, with its own reason.

* fix(ios): keep surface identity through comparison, find, and probe scope

A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an
unreadable or truncated environment could route a live sheet to the occluded app
tree. Only a scope naming a different device is a real negative now; a missing
one stays unknown.

The shared post-gesture comparison token used comparisonKey or the backend
alone, so an app capture and a sheet capture — both XCTest — compared equal and
a sheet appearing or dismissing read as a stable surface. The token now carries
the surface, which covers stabilization, verify and settle through the one path
they share.

Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the
shared disclosure helper could not report the sheet on either outcome. It is
preserved now.

Each fix has a regression that fails without it.

* fix(ios): keep surface identity in verify and settle comparisons

`--verify` compared node digests and `--settle` diffed node-only baselines, so an app
baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one
presentation: a meaningless changed verdict, and a whole-surface replacement presented as an
in-surface diff with refs.

The pre-action baseline now travels with the surface its capture described, from the resolution
and the session frame through to the settled capture, and one module owns the comparison for
both routes. Across a surface change no same-surface claim is made: evidence reports the
transition instead of a digest comparison, the settled diff and its refs are withheld, and both
payloads disclose the transition.

* refactor(test): move the cross-surface settle tests onto their source mirror

The #2438 cross-surface cases were appended to `settle.test.ts`, taking it over the
test-file size ratchet (2528 lines, 2359 at the merge-base). They assert the
comparison `post-action-surface.ts` owns, so they move to that module's mirror test
file, and the device double plus the trees both files drive move to a sibling
fixtures module under `__tests__/` rather than being duplicated.

Pure move: every test and every assertion is unchanged, and `settle.test.ts` is back
under its merge-base length.

* test(daemon): cover the cross-surface settle refusal on the generic route

`scroll --settle` and `back --settle` plumb the baseline's surface identity
through `baselineSurfaceBundleId`, but nothing asserted it: the generic route
had zero coverage of the #2438 refusal, so a regression there would have been
silent while the element-targeted route stayed green.

Assert the same contract the targeted route guarantees, in both directions and
for both commands: no diff is attached across an app/sheet boundary — therefore
no tail and no `refsGeneration` — the transition is disclosed, and the settle
observation still reports its own verdict alongside that disclosure.

Each direction falsifies a different half of the plumbing, so both are needed:
dropping the baseline's surface identity fails only the sheet-to-app tests (an
app baseline has no surface id to lose), and dropping the settled capture's
fails only the app-to-sheet tests. No production change: the plumbing was
correct, only untested.

* refactor(ios): inline the single-caller surface disclosure wrapper

iosSystemSurfaceDisclosure() only mapped provenance-or-nothing onto the shared
constant for one caller, so the caller now reads the constant directly and the
wrapper is gone. Its test becomes a test of the transition disclosure, which is
the function that still earns its place (the "sheet is gone" sentence).

readAppleSnapshotResult also called readSystemSurfaceProvenance twice inside one
spread; it is bound to a local and read once.

* docs(adr): state that a presented surface outranks a requested bundle id

prepareActiveCommandContext checks for a presented system surface before it
resolves or activates command.appBundleId, so a command naming a different app is
still served the sheet. That is intended, but the code does not read that way;
the amendment now says it plainly.

* refactor(ios): carry the system surface in the capture's comparison lineage

A capture of an in-place system surface (a web sign-in sheet) describes a
different presentation than a capture of the app, so it must never compare
equal to one. The `present` branch of the iOS snapshot route returned a bare
fallback, so that capture carried no comparison identity at all, and two
comparison sites hand-rolled the distinction from `iosSystemSurfaceBundleId`
instead.

The probe now reports which host it matched, and the `present` branch goes
through `runFallback` like the `unknown` branch beside it, lineaged to
`<device>:<host bundle>`. The comparison key then differs from an app
capture's by construction, so the surface branch in `hasMatchingPresentation`
and the surface concatenation in `snapshotComparisonKey` are gone: both sites
are plain key equality again, and neither knows that system surfaces exist.
Two captures of the same surface still share a lineage, so they stay
comparable with each other.

A presented surface is not a bridge failure, so it gets its own warning
wording: the bridge is inapplicable here, not unavailable.

* refactor(interaction): carry the pre-action baseline as one surface-scoped value

The same pre-action tree travelled as a flattened nodes/surface pair at every
boundary, and each boundary rebuilt it with a conditional spread. Carry
SurfaceScopedNodes itself instead:

- ResolvedInteractionTarget gets preAction?: SurfaceScopedNodes, replacing the
  preActionNodes/preActionSurfaceBundleId pair and the PreActionBaselineFields
  intersection on all three arms of the union.
- SettleObservationCommandOptions gets baseline: SurfaceScopedNodes, replacing
  baselineNodes/baselineSurfaceBundleId.
- RefResolution carries tree: SurfaceScopedNodes instead of nodes plus a loose
  surfaceBundleId.

That retires preActionBaselineFields(), preActionBaseline(), evidenceBaseline(),
the local SettleBaseline type, the split-then-reassemble in
settleObservationCommand, and the 'preActionNodes' in resolved narrowing tests.
SurfaceScopedNodes moves to contracts, where ResolvedInteractionTarget can name
it; only two sites now mint one from a SnapshotState.

Behaviour is unchanged: the cross-surface guarantees keep their existing tests.

* fix(ios): identify a surface capture by what the runner served

The `present` path stamped the capture's comparison lineage from the host-side
presence probe. That probe answers about a host PROCESS and deliberately stays
positive while a dismissed host lingers, so during that window the runner
truthfully returned APP content while the route lineaged it to the HOST: the
sheet capture before the dismissal and the app capture after it compared equal,
and a post-gesture poll could read the transition as a stable surface.

Derive the identity from the returned capture's `systemSurface` instead - the
runner stamps the surface it actually served - and say which of the two the
capture holds in the warning. The probe's host is now evidence only: it names
the matched host in a route diagnostic so a lingering window is legible in the
daemon log. Other reasons keep their lineage and wording byte for byte.

Captures that bypass the route's planning (a pinned backend, a custom-actions
read) also reach the runner, and the runner serves the sheet there too. They
carried no comparison key at all, so a sheet and app content fell through to
legacy presentation matching as one presentation and could corroborate a tap
across the two. The capture owner now gives those a surface-scoped identity as
well, with no fallback-source residue: nothing fell back. An app capture off
the route is untouched.

* fix(ios): derive a served surface identity at the one stamping point

A runner fallback's comparison identity was decided per call site. The
`present` path and the off-route path read the runner's `systemSurface`
stamp, but the plain `runFallback` path did not: it stamped the app
lineage the route had planned, whatever the runner returned.

The probe and the capture are separate observations, so a sheet can
appear in the gap between them. With the bridge circuit already disabled
for the generation, an app capture and a later sheet capture both
received the same app-generation key, so tap corroboration could treat
two different surfaces as comparable.

`stampFallback` now owns the decision for every runner fallback: the
surface the runner served outranks the app lineage the route planned.
The reason the bridge was skipped survives either way, and
app-generation evidence leaves with the app lineage it describes, so two
captures of the same sheet still compare equal. `runSurfaceFallback`
keeps only the reason, which is the one thing that path decides.
2026-09-11 17:37:35 +02:00
Ahmad Al-Faqih bda6d42c9a fix(test): reject reporter exit codes that can wrap to success (#2497)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 17:37:20 +02:00
Michał Pierzchała 1fb276448f fix(wait): poll through a retriable runner refusal instead of surrendering the budget (#2493)
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Live-verified against a genuinely wedged simulator runner: COMMAND_FAILED,
retriable true, runnerErrorCode RUNNER_BUSY, reason wait_capture_stalled,
captures 6, readableCaptures 0, waitedMs 8041, polls
retriable,retriable,retriable,retriable,retriable,deadline.
2026-09-11 17:37:11 +02:00
Prateek Ranka c94e66e9b8 fix(selectors): collapse an unverified-hittability wrapper chain to its control (#2482)
* fix(selectors): collapse an unverified-hittability wrapper chain to its control

A SwiftUI toolbar wrapper and its control share one identifier, and regular iOS snapshots omit hittability evidence. findPreferredActionableDescendant requires verified hittability, and the wrapper's rect differs by under a point per edge, so press/fill saw two distinct actionable elements for one control and refused with AMBIGUOUS_MATCH.

Resolve the deepest semantic touch target when every candidate lacks hittability evidence and all rects agree within sub-pixel slack. Candidates carrying any hittability fact keep the existing rules.

* fix(selectors): keep the wrapper-collapse fallback to non-actionable wrappers

Review follow-up on #2482. The unverified-hittability collapse accepted any
ancestry chain whose rects agreed within a point, so a cell and the button
inside it (both actionable, no hittability evidence) collapsed to the
descendant: a silent wrong-control press where the previous rules refused as
ambiguous. The fallback now requires every candidate above the control to be a
non-actionable wrapper, and a negative regression covers the semantic-ancestor
case next to the captured Other/Button success case.

Gate: pnpm check:affected --run - 304 files / 2011 tests, all runnable checks passed.

* perf(selectors): keep the wrapper collapse inside its budgeted closure

The extracted module grew the eager closure of three budgeted entries by one
module each -- interaction-targeting.ts 13 -> 14, selector-pipeline.ts 25 -> 26,
absence-observation-resolution.ts likewise -- and the eager-closure gate
ratchets that closure against the merge-base: an entry surface that drags more
of the repo onto the import path is a loading-shape regression whatever the
reason. The rule has exactly one consumer, so it now lives beside the
classification that asks it and is no longer an exported surface.

Its tests move to the owning module's test file and exercise
`classifyActionableTouchCandidates`, the boundary the command actually calls.
Each of the four refusals fails when its own guard is mutated: the hittability
condition, the 1 pt slack, the non-actionable-wrapper condition, and the
semantic-control condition.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-11 16:57:14 +02:00
Michał Pierzchała 38cfa87b6b chore(react-devtools): pin agent-react-devtools 0.5.0 for React Native 0.87+ (#2488)
* 0.21.1

* chore(react-devtools): pin agent-react-devtools 0.5.0 and document the React Native 0.87 setup step

agent-react-devtools 0.5.0 restores attachment on React Native 0.87+,
where the built-in DevTools websocket was removed (facebook/react-native#56897).
The app now needs a one-time `agent-react-devtools init` plus a rebundle;
the help topic says so, and warns that an empty observation is not a pass.

Verified live: a bare react-native@0.87.1 app attaches through
`agent-device react-devtools` with 149 components.

Refs #2430

* docs(react-devtools): tell agents to run uninit when the task is done

* docs(react-devtools): trim the help topic to the current setup facts

* docs(react-devtools): state the app dependency and the attachment check
2026-09-11 15:41:39 +02:00
Michał Pierzchała c7722ffcea fix(ios): reap orphaned simulator recorders with a graceful finalize window (#2457)
* fix(ios): hint the busy simctl recorder and detach recorders gracefully

Classify `simctl recordVideo` exit 16 (POSIX EBUSY) as a typed COMMAND_FAILED with
an actionable hint and the recorder's stderr instead of an unclassified UNKNOWN, so
a caller learns another recording holds the one host-wide CoreSimulator slot or a
prior recorder died without detaching.

Give a recorder that is rolled back or reaped the same graceful SIGINT -> SIGTERM ->
SIGKILL escalation the live stop path already uses, and widen the daemon-startup
reaper's recorder grace to match it, so finalize-and-detach releases the host-wide
recording lock instead of a mid-detach SIGKILL dangling it for every later recording.

Closes #2170

* refactor(ios): inline the busy-recorder classifier and drop the rollback helper

Simplify #2170. The exit-code classification lives beside `startError` that is its only
caller, so drop the separate module and its test and assert through the real start path
instead. Test through `normalizeError` to keep the wire-shape (hint lifted, reason and
exit code retained) guarantee.

Revert the start-rollback SIGKILL sites: the identity arm fires only when the recorder
already exited, and the acquisition rollback discards an explicitly canceled recording, so
fast-kill there is fine. The dangling-host path is the startup reaper, which keeps the
wider finalize grace added earlier.

* refactor(ios): scope this change to the daemon-startup recorder reap grace

The exit-16 classifier and graceful start rollback are owned by #2447 with the DEVICE_IN_USE contract; drop the overlapping COMMAND_FAILED classifier here to keep one start-path error contract. Keep only the non-overlapping daemon-startup fix: an orphaned simctl recorder is reaped with the same finalize grace the live stop path allows, plus a startup-wiring test. Refs #2170.
2026-09-11 15:40:46 +02:00
Michał Pierzchała fd4cee83f8 fix(android): retire completed recording evidence after pid reuse (#2487)
* fix(android): retire completed recording evidence after pid reuse (#2476)

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

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

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

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

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

Review follow-up. A reused pid that runs screenrecord on the recorded remote
path with a different start time proved the old recorder gone, and retirement
read that as permission to remove the artifact — deleting the replacement
recording's active MP4. Classify that observation as foreign-writer in the
contract: it still proves termination, so recovery and the truncation warning
keep reading it, but it never proves the path unclaimed, so retirement retains
the marker and artifact until the replacement ends and never signals it.
Stop-wait refuses it like ownership-lost.
2026-09-11 15:39:02 +02:00
Ahmad Al-Faqih c08545897c fix(test): expand relative globs from the literal working directory (#2490)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 14:19:22 +02:00
Prateek Ranka eb0d791957 fix(orientation): disclose an unconfirmed rotation instead of asserting it (#2483)
* fix(orientation): disclose an unconfirmed rotation instead of asserting it

executeSetOrientation fell back to the requested rotation when the owner reported no resulting orientation, then reported 'Rotated to <request>' as a success claim. Keep the requested rotation for compatibility, but mark the claim unconfirmed and warn.

* fix(orientation): carry the unconfirmed rotation through the journal and public surface

Review follow-up on #2483. The daemon disclosed `confirmed: false` plus a warning,
but the surfaces that consume the result still asserted a rotation:

- `buildOrientationActionSummary` rebuilt "Rotated to <orientation>" from the
  orientation field alone, so a session journal recorded an unconfirmed rotation as
  fact. It now records "Requested <orientation> (unconfirmed)" when the owner
  reported nothing; the journal regression fails on the previous commit.
- `OrientationCommandResult` declares the optional `confirmed` and `warning`
  fields, and the MCP output schema advertises them so clients can consume the
  distinction (the navigation schema parity test covers the lockstep).

The disclosed-warning shape stays: no hard failure.

Gate: pnpm check:affected --run - 332 files / 2158 tests, all runnable checks passed.
2026-09-11 14:14:17 +02:00
Michał Pierzchała 47b1cae548 fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) (#2486)
* 0.21.1

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

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

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

Adds a fixture-backed smoke scenario that drives the WebView lab through the
default route, amends ADR 0004 and the bridge README, and shares the e2e
snapshotNodes helper.
2026-09-11 13:12:57 +02:00
Michał Pierzchała df0a0f7fd2 perf(package): strip comments from the Apple runner source the npm package ships (#2467)
* perf(package): strip comments from the Apple runner source the npm package ships

The packager copies apple/runner/** into dist/ as Swift source, removing only
its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were
downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift.

Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*`
open a comment only in code position, raw literals move their own delimiter and
escape with the `#` count, interpolation segments hold code and further
literals, and Swift block comments nest. A construct the scanner cannot account
for throws at packaging time instead of shipping Swift that does not compile.

* fix(package): keep Swift regex literals out of the comment scanner

`#/foo//bar/#` is a valid extended regex literal with no comment in it, but the
scanner only knew the `#"` raw-string family, so it read the literal's `//` as a
line comment and shipped `let pattern = #/foo` — Swift that does not compile.
Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the
single- and multi-line forms, Swift's own-line rule for a multi-line closing
delimiter, and the `\/` escape that keeps one from closing early.

Bare `/…/` literals stay unresolvable, because the same `/` opens a comment,
divides, and starts a regex literal, and only the parse separates them. Where
one could begin — an expression position whose `/` is not followed by a space,
a tab or `)` — packaging throws by file and line instead of rewriting bytes it
cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the
recording scripts' shebang and `(/)` keep flowing through.

* fix(package): keep the packaged runner source on the checkout's line numbers

`dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a
file and line in (it lands in runner.log), so those numbers are only worth
reading if they point at the same line of `apple/runner/**`. Both rewriting
passes now empty the lines they remove instead of deleting them: comment removal
(889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was
moving everything below a block by up to 883 lines (3,737 lines, 3,737 B).

`dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone
441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the
previous head saved.

Nothing in the repo compiles the packaged source, so a mis-lex that failed to
throw would ship Swift that does not build and no gate would see it. Add
`pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts
line-count parity plus the line of every declaration each packaged file still
carries, then runs `swiftc -parse` over all 44 files. The parse half reports
itself skipped where no Swift toolchain exists, so the gate is declared on the
macOS lane, where both halves run.
2026-09-11 12:00:02 +02:00
Ahmad Al-Faqih 3d503a0e88 fix(test): emit well-formed JUnit XML for replay results (#2477)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-11 10:41:41 +02:00
Brad Anderson f57b42166a fix(network): report iOS requests that reused a keep-alive connection (#2433)
* fix(network): report iOS requests that reused a keep-alive connection

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

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

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

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

Refs callstack/agent-device#2430

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
2026-09-10 20:44:16 +02:00
Ahmad Al-Faqih 72ccf1a476 fix(maestro): use canonical deep-link classification for exports (#2463)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-10 20:43:18 +02:00
Michał Pierzchała 220bab08ba chore(gates): name the added modules and import paths when an eager closure grows (#2471)
* chore(gates): name the added modules and their import paths when an eager closure grows

The no-growth diagnostic in scripts/__tests__/eager-closure-budgets.ts only named the
FIRST newly evaluated module and always advised a dynamic import. On #2423 that sent
five reviewers toward the wrong fix when the growth was a small new module that
belonged in a module every affected entry already evaluated -- the dynamic-import
advice was never coherent for a brand-new module with no old edge to defer.

- describeClosureGrowth now lists every added module (bounded to 10), each with the
  shortest static import route from the entry to it.
- describeSharedGrowthHomes runs once after every entry is evaluated: when two or more
  entries grew by the same added module, it names the modules they already evaluate at
  the merge-base under that module's own package -- candidate homes, not a verdict.
- classifyGrowth's closing advice now states the two common causes (a new static edge,
  or something that used to load lazily) and the two remedies (give the symbol a home
  in a module already in the closure, or make the new edge lazy) instead of prescribing
  one fix.

The verdict logic (when an entry is flagged as having grown) is unchanged.

* chore(gates): split the shared-growth-homes diagnostic into small helpers

* chore(gates): aggregate only net growth and keep shared homes per added module

The cross-entry shared-homes note took every entry with a newly evaluated
module, which is not the condition the per-entry rule applies: a closure that
swaps one module for another, or shrinks while adding one, has added modules and
no growth. `classifyGrowth` passes it, so the aggregate must too -- entries now
carry their head closure size and the grouping keeps only the ones whose closure
actually grew.

Candidate homes are no longer unioned across added modules. Each added module
shared by two or more grown entries gets its own block naming those entries with
how much each grew and the merge-base modules exactly those entries evaluate, so
the label no longer claims a home is common to every failing entry when two
independent groups are in play.
2026-09-10 20:10:01 +02:00
Michał Pierzchała f4c8f3ddda refactor(apple): carry one phase Deadline through the runner interfaces and test cancellation as a matrix (#2473) 2026-09-10 18:44:50 +02:00
Michał Pierzchała fea7ca8a43 chore(layering): runner modules reach host-kit only through the runner host port (#2470)
R77 apple-runner-host-port bans a direct @agent-device/host-kit/* value
import from packages/platform-apple/src/runner/**; the port at runner/host.ts,
bound in core/runner-host.ts, is the only door. runner/** sits in the eager
closure of seven Apple facade entries eager-closure-budgets.ts holds at a
fixed size, so a direct import grows all seven at once (#2423 measured one
candidate import adding 5 modules to runner/index.ts's closure, 13 -> 18,
after two review rounds spent rediscovering this).
2026-09-10 18:44:36 +02:00
Michał Pierzchała 3e89f821d6 fix(cli): declare projectConfig on the scroll --until flag (#2472) 2026-09-10 18:00:56 +02:00
Michał Pierzchała 6d08de4609 feat(scroll): find off-screen targets in one command with --until (#2436)
* refactor(interaction): extract the scroll command runtime out of gestures.ts

* feat(scroll): add --until <selector>, report honored travel, fix web amount units

* test(scroll): cover --until through the provider-backed integration path

* perf(selectors): keep the scroll-until predicate off the eager import path

* fix(scroll): refuse an unreadable capture instead of reporting end-of-content

* fix(selectors): keep the capture-readability check off the eager import path

* test(selectors): use a declared snapshot quality state in the capture fixtures

* fix(scroll): read the capture quality verdict under the spelling the backend uses

* refactor(scroll): collapse --until onto the one route that runs it

* refactor(scroll): drop unexported until types and duplicated guidance prose

* test(scroll): fix the climbing fixture and drop duplicated route-level cases

* refactor(scroll): delete the dead command-runtime executor and reuse canonical predicates

* refactor(interaction): keep requireResolvedPoint local to the gesture runtime
2026-09-10 17:13:26 +02:00
Michał Pierzchała da76aa4f1e refactor(commands): declare project-config admission and recorder sanitization on the flag declaration (#2453)
* refactor(commands): declare project-config admission and recorder sanitization on the flag declaration

Move the two fail-closed flag properties — may a key be set from a project
`agent-device.json`, and does the session recorder copy it into `SessionAction.flags`
— off the hand-maintained allowlists and onto each `FlagDefinition` as required
`projectConfig` / `recorded` fields. Omitting either is now a type error, so the
compiler holds the fail-closed property a list held by omission.

- 156 declarations carry both fields; the 6 screenshot-specific definitions carry them
  too. Populated to match the old sets exactly (one-off diff empty: 85 project-config
  and 39 recorded keys, byte-for-byte).
- `cli-config.ts` and `session-action-recorder.ts` derive their sets from the registry
  and no longer list keys; `RECORDED`/`PROJECT_CONFIG` derivations recomputed per call so
  a consumer builds its set at its own module load. Recorder reaches the derivation
  through the `cli-schema/command-schema.ts` seam (daemon may not import `commands/`).
- Planted-divergence tests, per #2421: flipping one declaration's field moves the
  admission/sanitization outcome through the production derivation, plus a compile-time
  pin that an incomplete declaration does not build.
- `docs/agents/cli-flags.md` now points at the declaration fields, not the allowlist.

Refs #2445

* refactor(commands): return the recorded keys as a set, matching project-config

Both derivations answer the same question — the set of flag keys a surface admits —
so both return ReadonlySet<FlagKey>. Drops a needless set-then-spread on the
recorder path; consumers already iterate the value.

Refs #2445

* fix(commands): keep the CommandFlags guard on recorded flag declarations

The deleted `SANITIZED_FLAG_KEYS` was `satisfies readonly (keyof CommandFlags)[]`,
so every recorded key had to be a `CommandFlags` key. The derived set returns
`FlagKey` and the recorder indexed it through a cast, so `recorded: true` on a
CLI-only key (`daemonAuthToken`, `help`, …) compiled and could leak an uncarrable
value into a recorded action.

State the constraint on the declaration: `FlagDefinition` is a union that locks
`recorded` to `false` for a `NonRecordableFlagKey = Exclude<FlagKey, keyof CommandFlags>`.
`recordedFlagKeys()` returns `ReadonlySet<RecordableFlagKey>` via a narrowing
predicate, so `sanitizeFlags` drops its cast. Adds a `@ts-expect-error` test that a
CLI-only key cannot opt into recording.

Refs #2445
2026-09-10 17:04:32 +02:00
Michał Pierzchała 2d109e5cc5 refactor(contracts): additive capability facts on the clipboard family (#2464)
* refactor(contracts): make clipboard capability facts additive

Additive capability facts on the clipboard family (#2443, family 3).

clipboardRuntimeOperationFacts took exactly two cells, read and write, and both are
required. The halves stay separate claims — a WebDriver provider whose Appium clipboard
extension exposes only a getter is a real owner with one half and not the other, and
`clipboard read` must not be refused because the write half is missing — so both are
optional and `unsupported` names the denial an unnamed half reports. A call naming no
denial is still refused: omission is a classified refusal, never an unclassified half
and never an implied success.

Six of the eight owners drive both halves from one shell command set or one leaf gate,
so they now state that once: web, HarmonyOS, and Vega name a single denial, and the
shared unavailable record carries one clipboard cell where it carried two. The owners
that serve clipboard keep naming both halves, and the one install-source double that
enumerated the two keys by hand now goes through the family builder like every other
construction site.

Fact values are unchanged: every owner's clipboard cells are byte-identical, and the
family's own test pins an unnamed half reporting the stated denial verbatim, an owner
naming nothing answering with the exhaustive shape, and the freeze.

* fix(contracts): derive clipboard family denials from the served cell

Review follow-up on the clipboard family.

Apple restated its leaf branches to choose a clipboard denial, so the leaf split lived
twice in one file. It now reads the leaf's own refusal, keeping a leaf-scoped
placeholder only where the leaf serves clipboard and therefore has no clipboard
refusal to state.

Android named a build-level shell verdict as its family denial, which is the claim its
own probe refuses to make when the probe did not complete; the denial now follows the
probed cell, so an unnamed half reports the unknown rather than a verdict.

Pin the shared unavailable record's clipboard fan-out the way the keyboard commit
pinned its own: one input cell, two operations, each with its reason.
2026-09-10 17:02:32 +02:00