428 Commits

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

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

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

Closes #2550

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

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

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

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

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

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

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

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

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

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

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

Each inconclusive writer-scan scenario rebuilt the same scoped adb fake and the
same cleanup transport. The transport cases now differ only in which candidate
reads fail, and the owned-cleanup cases differ only in the scan they return, so
both run from one table against the same assertions.
2026-09-14 11:52:56 +02:00
Michał Pierzchała 4f5a87b6b3 refactor(command-registry): move CLI flag grammar, text and command aliases down (#2561)
Move the vocabulary that both the CLI and commands read but no command's runtime
depends on into the package below both: flag types, registry, groups and the four
flag-definitions files, command-text, and cli-command-aliases. These are pure moves;
only their import specifiers change.

No compat re-export at the old paths — every consumer switches to the owning
subpath. The per-command defaults stay where they are for now (the daemon's edge into
the facet resolver is the harder cut and belongs with the daemon-closure work).

Part of #2545 / #2543.
2026-09-14 11:27:22 +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 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 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
Michał Pierzchała 1fb276448f fix(wait): poll through a retriable runner refusal instead of surrendering the budget (#2493)
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review follow-up. A reused pid that runs screenrecord on the recorded remote
path with a different start time proved the old recorder gone, and retirement
read that as permission to remove the artifact — deleting the replacement
recording's active MP4. Classify that observation as foreign-writer in the
contract: it still proves termination, so recovery and the truncation warning
keep reading it, but it never proves the path unclaimed, so retirement retains
the marker and artifact until the replacement ends and never signals it.
Stop-wait refuses it like ownership-lost.
2026-09-11 15:39:02 +02:00
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 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 d80b021fa5 refactor(contracts): make keyboard capability facts additive (#2459)
The keyboard family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it serves and omission
reports that denial verbatim, with the reason and hint the owner would otherwise
have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. An owner that names every operation still states the
family refusal, and it must refuse the family rather than one operation of it —
whatever the owner leaves unnamed reports that cell verbatim.

The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/
`keyboardEnter` into one required `keyboard` cell, so the owners that answered
the family with raw keys (webdriver, vega, linux, limrun's no-session binding)
now answer it through the builder, which is the family's one entry point.

Facts are unchanged for every owner, so the per-platform admission assertions
hold untouched. Two owner tests now assert the keyboard reason as well as the
availability, because one family cell reports one reason for all three
operations: the WebDriver inactive session and the stale Limrun identity.

Refs #2443
2026-09-10 17:02:31 +02:00
Ahmad Al-Faqih 3bbeb61917 fix(snapshot): compare unchanged presentations by value (#2442)
* fix(snapshot): compare unchanged presentations by value

* test(android): verify unchanged snapshot output live

---------

Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-10 14:12:41 +02:00
Michał Pierzchała 41e2633f10 fix(wait): retire native selector bypass and recover text observations (#2440)
* fix(apple): resolve selector waits through canonical capture

* fix(wait): retire dead selector observations and recover native text failures
2026-09-10 12:03:58 +02:00
Michał Pierzchała 6ca66c9fad fix(android): ease controlled scrolls within the requested duration (#2393) 2026-09-10 10:15:34 +02:00
Michał Pierzchała edbd96ea30 refactor(contracts): additive capability facts pilot on touch family (#2420)
* refactor(contracts): make touch capability facts additive

The touch family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it implements and
omission reports that denial verbatim, with the reason and hint the owner would
otherwise have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. Facts are unchanged for every owner, so the per-platform
admission assertions hold untouched.

Refs #2412

* fix(contracts): keep tap/longPress/fill required in touch facts

Making every touch cell optional dropped the compile-time guard on the
three operations every owner implements, with nothing replacing it: an
owner could silently drop `tap` and nothing would fail. Only the truly
per-owner cells (tapRef, hover, hoverRef, fillRef, tapElementSelector)
need `unsupported` as their default.

Restoring the required fields caught a real regression this refactor
introduced: platform-web's touch facts had silently lost their explicit
`longPress: readinessUnavailable` cell, so longPress reported the
family's `unsupported` fallback instead of the owner's own readiness
reason. Restored it, and fixed the other now-required-field call sites
(the fully-unavailable owner fixtures, and the screenshot-runtime-fixture
test double, which had also silently lost its longPress/fill distinction).

Refs #2412

* fix(coverage): repoint hover evidence off the retired runtime.ts source line

The touch family's additive-facts refactor dropped every owner's explicit
hover: unavailable cell in favor of the family-level unsupported fallback,
so the coverage manifests' literal-substring checks against
packages/platform-apple/src/runtime.ts and packages/platform-linux/src/runtime.ts
broke: that source line no longer exists.

Point the macOS, iOS, tvOS, and Linux hover rows at new tests that assert the
typed unavailable denial directly against each owner's bound facts, instead of
re-adding a hand-written denial line the refactor was meant to retire.

Refs #2412

* test(coverage): point hover evidence at the typed-denial tests in the declaration table
2026-09-09 22:16:45 +02:00
Michał Pierzchała 9d7d60c5e0 test(coverage): declare every public command's coverage judgments once (#2418)
* test(coverage): declare every public command's six platform coverage judgments once

One row per public command in test/integration/command-coverage/declarations.ts
carries the android-emulator, ios-simulator, macOS, tvOS, web and Linux
classifications with the same fields the six per-platform manifests use today.
Each platform's Record<PublicCommand, ...> is projected from that table at load
time by a small per-platform view module, so no projected record is committed.

No judgment is derived from another platform's: all six stay authored per command.

* test(coverage): read the projected per-platform coverage view

The six coverage smoke tests, live harnesses and coverage reports now import the
platform view module that projects the declaration table. Both the depgraph
blast-radius query and the device-lane test follow the iOS and macOS paths.

* test(coverage): delete the six per-platform coverage manifests

Their rows now live once, per command, in the declaration table; each platform's
record is projected from it at load time.

* fix(check-affected): extend macos-coverage and integration-node ownership to command-coverage/

test/integration/command-coverage/declarations.ts now carries the per-command
coverage judgments that used to live directly under test/integration/macos-e2e/.
Its nested path wasn't matched by macosCoverageOwnership (top-level or macos-e2e/
only) or isNodeIntegrationPath (no nested segments), so it fell through to
vitest-related, which can't actually run its node --test consumers. Extend both
rules to also match test/integration/command-coverage/.
2026-09-09 15:55:00 +02:00
Michał Pierzchała fef0b12cc5 chore: hoist shared snapshot/selector test fixtures into a single canonical location (#2419)
* chore: hoist shared snapshot/selector test fixtures into @agent-device/selectors

PR #2397 left two copies of the snapshot-state builder and duplicated
geometry/touch-point arbitraries (root's src/__tests__/test-utils/ and the
package's internal/__tests__/), because packages cannot import root src/.
Move the canonical versions into a new @agent-device/selectors/test-fixtures
subpath and have both root and the selectors package import from it, leaving
buildNodes and the root-only replay/gesture arbitraries in place.

Fixes #2402

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

* chore: exempt test-fixtures.ts's test-only arbitraries from dead-code check

PROPERTY_RUNS, scrollingContainerTypeArb, distinctRectPairArb, and
interactionTouchPointScenarioArb are consumed only by *.test.ts files, which
Fallow's --production analysis does not see, matching the existing pattern
for other workspace-package symbols reached only from the test tree.

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

* chore: consolidate makeSnapshotState into capture-kit, rename fixtures file

An adversarial review of the #2402 fixture-hoisting change found a third
copy of makeSnapshotState in packages/capture-kit/src/snapshot-state.fixtures.ts,
predating PR #2397. Since @agent-device/selectors already depends on
capture-kit, make capture-kit's copy canonical (exported as
./snapshot-state-fixtures) and have the selectors package's fixtures module
re-export it instead of duplicating it a third time.

Also rename packages/selectors/src/test-fixtures.ts to
snapshot-geometry.fixtures.ts (subpath ./snapshot-geometry-fixtures) to match
every other test-fixture module's *.fixtures.ts convention in this repo,
which lets it fall under .fallowrc.json's existing blanket **/*.fixtures.ts
dead-code exemption instead of needing a bespoke per-symbol entry.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 13:56:40 +02:00
Michał Pierzchała 8d5ca680c0 refactor(move): move the selector pipeline and interaction targeting into @agent-device/selectors (#2397)
* refactor(move): move the selector pipeline and interaction targeting into @agent-device/selectors

The 11 pipeline modules (selector-pipeline, selector-pipeline-policy,
interaction-targeting, touch-semantics, interaction-positionals,
press-retarget, interaction-touch-point, absence-observation and its
errors/resolution companions, and the interaction-error vocabulary) are
exposed as per-file subpaths. The two test-utils files the moved tests
share with root tests are copied into the package, following the
existing package-local test-utility pattern.

* chore(gates): point the layering pins at the moved selector pipeline

R19's owner constant now names the pipeline in
packages/selectors, and the rule additionally refuses in-package
relative routes to the engine file so the co-location cannot widen the
door. The package-boundaries export/dependency pins and the fallow
health baseline key follow the files.

* fix: drop two unused exports flagged by fallow

* test: point press-retarget comment at the relocated touch-semantics module
2026-09-09 08:28:34 +02:00
Jiacheng c61b6ed407 feat(remote): add HarmonyOS proxy lease backend (#2266)
* feat(remote): add HarmonyOS proxy lease backend

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

* feat(remote): add HarmonyOS proxy lease backend

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

* feat(remote): complete HarmonyOS lease backend wiring

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

* feat(remote): complete HarmonyOS lease backend wiring

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

* fix(remote): accept Harmony runtime hints

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

* fix(remote): complete Harmony runtime lease plumbing

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

* test(wire): acknowledge Harmony lease additions

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

* test(remote): close HarmonyOS lease review gaps

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

* fix(runtime): update HarmonyOS support error text

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

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

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

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

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

---------

Signed-off-by: Ark <artin@cat.ms>
Signed-off-by: Ark <raft-mobile-ark@mail.build>
Co-authored-by: Ark <raft-mobile-ark@mail.build>
2026-09-08 21:59:06 +02:00
Michał Pierzchała 06773095c6 fix(android): honor scroll releaseBehavior to stop the fling overshoot (#2372)
* fix(android): honor scroll releaseBehavior to stop the fling overshoot

scrollAndroid ignored ScrollReleaseBehavior and always released the pan
like a fling, so a controlled `scroll` overshot its requested distance
by ~60% on both a plain RecyclerView and an RN ScrollView. Android's
VelocityTracker treats a truly stationary release as a resampled
duplicate and ignores it, so a hold alone can't stop the fling.

For the default 'controlled' release, append a short tail after the
pan's endpoint that holds the scroll axis exactly fixed (zero velocity
there) while nudging the orthogonal axis every frame so no two
consecutive samples repeat. 'inertial' (the scroll top/bottom edge
passes) is unchanged. Live-measured on a Pixel 9 Pro XL emulator:
displacement lands within touch-slop of the requested drag instead of
overshooting it.

Fixes #2371

* fix(android): cap the controlled-release tail at the gesture duration ceiling

The release tail was appended after buildGesturePlan's own duration
validation, so a maximum-duration (10000ms) scroll silently produced a
10160ms plan beyond GESTURE_DURATION_MAX_MS. Cap the tail so the
dispatched plan never exceeds that shared ceiling, shrinking (and, at
the exact maximum, dropping) the tail near the boundary instead of
truncating the requested move. Add regression coverage for the exact
maximum and near-maximum cases.

Also fix the stale android-lifecycle provider-integration expectation:
a plain scroll resolves to 'controlled' release by default, so its
dispatched plan is now 510ms (350ms move + 160ms tail), not 350ms.

* fix(android): reject a controlled scroll that leaves the release tail no room, instead of shrinking it

The previous fix capped the tail near GESTURE_DURATION_MAX_MS, which
silently dropped the fling-suppressing braking step for scrolls close
to the ceiling -- defeating the point of the fix exactly where a long
scroll needs it. A controlled scroll's own accepted duration range now
stops CONTROLLED_RELEASE_TAIL_MS short of that shared ceiling
(9840ms), rejecting anything past it with a clear INVALID_ARGS error
instead of truncating the move or the tail. Every accepted controlled
scroll now runs the full, unshortened tail; 'inertial' releases are
unaffected and keep the full 10000ms range.

Also drop the multi-line explanatory comment on the corrected
android-lifecycle.test.ts expectation (a same-line comment instead)
so the fix doesn't grow that file past its line-count tripwire.

* docs(scroll): describe reduced momentum and the Android controlled-scroll limit

The scroll docs only carried the generic app-physics caveat, not the
release-behavior contract itself. State plainly that scroll releases
with reduced momentum rather than an exact stop, and document
Android's controlled-release durationMs ceiling (9840ms, reserved
headroom for the braking tail added in #2371) and that a longer
request is rejected rather than silently truncated.
2026-09-08 21:52:20 +02:00
Michał Pierzchała 0627190524 refactor(move): move lease scope vocabulary into @agent-device/contracts (#2380)
* refactor(contracts): move the lease scope vocabulary into @agent-device/contracts

* chore(gates): pin the contracts lease-scope subpath in the exports snapshot

* fix(tests): re-point remote-proxy-parity lease-scope import to contracts

The test landed on main after this branch cut and still imported the
pre-move path src/core/lease-scope.ts; use the
@agent-device/contracts/lease-scope subpath like the other consumers.
2026-09-08 21:52:00 +02:00
Michał Pierzchała c5d9707196 refactor(daemon): move the device selection cluster into @agent-device/device-selection (#2396)
* refactor(daemon): move the device selection cluster into a workspace package

git-rename src/core/dispatch-resolve.ts, src/core/device-selection-resolver.ts and src/request/device-inventory-context.ts (plus their tests and fixtures) into a new private package @agent-device/device-selection (deps: contracts, host-kit, kernel). One subpath per module points straight at the moved file; no index.ts, no re-export at the old path. Consumers switch to the owning specifier in the follow-up commit.

* refactor(daemon): install the installed-app probe on the inventory gateways and switch consumers to the device-selection package

The composition root (src/platform-runtime-device-inventory.ts) now attaches an optional findInstalledApp probe to the composed device inventory gateways, lazily importing the Apple simulator app-resolution mechanics. Device selection reads the probe from the request context instead of importing a platform package directly, so absent probes fall back to the ordinary inventory rules. All consumers switch to the @agent-device/device-selection/{dispatch-resolve,device-selection-resolver,device-inventory-context} subpaths; the moved tests carry a package-local inventory test util.

* chore(gates): re-point the layering gates at the device-selection zone

TARGET_DAG_RANK gains the device-selection leaf zone and drops the retired request zone; the back-edge fixture and the capture-kit ALS substrate fixture move to the package path.

* test(daemon): prove the factory-installed app probe narrows simulator selection

The moved selection tests inject their own probe, so nothing covered the
composition root actually attaching findInstalledApp to the gateways:
omitting it would leave those tests green while app-based selection
silently fell back to the generic local rules.

The new case runs two booted simulators through
createComposedDeviceInventoryGateways and the request context, fakes
only the leaf xcrun spawn (core tool-provider), and asserts the
single-app-installed-local selection plus both probe consults.
2026-09-08 21:51:41 +02:00
Michał Pierzchała 22a46d12d2 refactor(move): move the remaining package-ready modules out of src/core (#2401)
* refactor(move): move remaining package-ready modules out of src/core

- validation.ts            -> @agent-device/kernel/validation
- android-system-surface-disclosure.ts -> @agent-device/contracts/android-system-surface-disclosure
- project-runtime.ts       -> @agent-device/host-kit/project-runtime
- runtime-transport-hints(.test).ts -> @agent-device/host-kit/runtime-transport-hints
- app-events.ts (+tests)   -> src/daemon/app-events.ts
- dispatch-payload.ts (+test), payload-input.ts -> src/daemon/
- fill-backend-result.ts (+test) -> src/daemon/

interaction-outcome.ts stays in core: it correlates ResolvedInteractionTarget
with error objects across the commands(rank3)/daemon(rank4) boundary, and
contracts explicitly refuses mutable interaction-outcome lifecycle (R18).

* chore(gates): pin the exports and boundary snapshots for the moved core modules
2026-09-08 21:50:14 +02:00
Michał Pierzchała 1f9d940bff refactor(capture-kit): complete ADR 0019 end state — relocate snapshot and recording zones (#2385)
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit

Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:

- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
  freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
  packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
  retires with its last file)

Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.

* refactor(capture-kit): re-point capture and recording consumers to the new subpaths

Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.

* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation

Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.

* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules

* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation

* test(capture-kit): update upstream presentation test imports

* chore(gates): follow relocated snapshot assembly in R74

* test(daemon): freeze prewarm deadline assertion clocks
2026-09-08 12:41:39 +02:00
Michał Pierzchała 13a45b9632 test(remote): assert the proxied snapshot's whole wire conversation (#2387)
#2198 required proof that "the bridge adds no network round trip and transfers
only the published response". That was read off the RTT benchmark — identical
response bytes, unchanged wall-clock slope — which is inference, not proof, and
the raw-result schema carries no round-trip count.

Assert it instead. Record every request the proxy forwards upstream while one
`snapshot -i` runs, and pin the whole conversation rather than just the RPC, so
a new call of any kind fails. The bridge contributes nothing to it: no helper,
admin or acquisition route appears.

Then read what crossed rather than sizing it, at every level of the payload —
envelope, result, published data — each against its declared key set. Comparing
the wire's result against the client's response proves nothing on its own, since
the client publishes whatever the result holds and both sides move together.

Refs #2198.
2026-09-08 10:37:08 +02:00
Michał Pierzchała 367e795ee7 fix(remote): let a plain-session client read its own failure record (#2382)
`GET /sessions/<session>/requests/<requestId>/diagnostics` applied the
`<tenant>:` prefix rule to every caller carrying a tenant, but
`scopeRequestSession` only writes that prefix under tenant isolation — which
the daemon forces exactly when the auth hook ATTESTS the tenant. A client
whose tenant is only declared (the `x-agent-device-tenant` header on a daemon
with no auth hook) therefore ran in a plain session such as `default` or
`cwd:<hash>:default` and was then refused 401 reading the record its own
failed command wrote, directly and through `agent-device proxy`.

The addressability rule now lives beside the naming rule in
`session-tenant-scope.ts`, which exists so the two cannot disagree.
`isTenantAddressableSessionName` takes the caller's session namespace and
applies the prefix rule only where the namespace is actually partitioned;
`resolveTrustedTenant` now reports whether the tenant was attested, and
`authorizeAuxiliaryHttpRequest` hands that namespace to the route.

The attested case is unchanged: an attested tenant is still refused any
session outside its own prefix, with the same typed UNAUTHORIZED error.
2026-09-07 18:26:16 +02:00
Michał Pierzchała cc9fd725a1 test(android-e2e): read the logcat tail for rotation evidence (#2359)
The first CI failure with the evidence hook (PR #2356, run 34029660070)
lost its logcat section: dumping the emulator's whole 2MB buffer took
longer than the 5s per-probe bound on the loaded host. The probe now
reads the last 4000 lines, which holds the rotation decisions of the
last minutes and returns well inside the bound.
2026-09-07 10:14:34 +02:00
Michał Pierzchała 64b7cc45d4 fix(android): return from orientation once the display reports the rotation (#2356)
`orientation` wrote accelerometer_rotation and user_rotation and returned
at once, while the display rotated some time later. On the loaded CI
emulator that takes seconds, and accessibility reads hang meanwhile: the
Android smoke's `wait text landscape` right after `orientation
landscape-left` got a helper request timeout and then no readable
capture for its whole 10s budget, with the failed-step snapshot taken
afterwards already in landscape (PR #2344, run 34025424834).

The command now polls `dumpsys display` for mCurrentOrientation to
match the requested rotation before returning, each probe bounded by
what is left of the 15s settle budget so a stuck probe ends the settle
as a failure. A display that never gets there fails the command with the
observed rotation instead of reporting success; a display that reports
no rotation at all is left to the setting as before. The provider
scenario scripts the display read against the last user_rotation write.
2026-09-07 10:14:09 +02:00
Michał Pierzchała 7bcbf1350b feat(remote): proxy parity for Simulator observation (#2198 slice B) (#2351)
* feat(remote): give a proxied client the daemon's own failure envelope, cancellation, and version check

#2198 slice B. Direct-daemon and proxy execution over the same deterministic Simulator fixture
now publish the same responses, and the three places where they did not are closed:

- A client that disconnects mid-request behind the proxy now cancels the daemon request. The
  proxy's upstream fetch is bound to its client's connection, so the daemon's own disconnect
  cancellation (`markRequestCanceled`) fires exactly as it does for a direct client.
- The proxy forwards `GET /sessions/<session>/requests/<id>/diagnostics` (#1801), so a remote
  client behind it localizes a failure's diagnostics record instead of reporting
  `logPathUnavailable: HTTP 404`. GET only; the route still enumerates nothing.
- The client's ADR 0006 health check reads the `upstream` link a proxy's /health already nests:
  a proxy whose daemon speaks another RPC protocol fails at health, before the command RPC.

The provider scenario harness exposes its request boundary so a scenario daemon can sit behind a
real HTTP server and proxy; the new parity suite runs one script direct and proxied and compares
the published responses with transport identity removed, and proves two proxied clients
contending for one device fail at claim admission before any lifecycle call.

* test(remote): a proxied lease that expires tears its session down and a reacquired lease starts clean

#2198 acceptance: lease expiry, session cleanup, and device reacquisition do not reuse prior
capture or comparison state. The parity world takes a clock-driven LeaseRegistry; a leased
session behind the proxy captures a diff baseline, its lease lapses past the proxy TTL, the next
request is refused as UNAUTHORIZED/LEASE_NOT_FOUND with the session torn down, and a freshly
allocated lease reopens and reports baselineInitialized on its first diff.

* test(remote): a proxied lease heartbeat renews the lease and keeps the session's comparison state

#2198 acceptance: lease heartbeat through the proxy. A leased session behind the proxy captures a
diff baseline; an explicit lease_heartbeat RPC just before the lease's reported expiry moves the
expiry forward; a request past the old expiry but inside the renewed window still finds the
session and reports the baseline. Red without the renewing heartbeat (LEASE_NOT_FOUND).

* test(remote): follow the daemon client and session artifact path moves
2026-09-07 10:12:59 +02:00
Michał Pierzchała d26b0786fb perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198

* test(fixtures): share one inert audio-probe host across the platform runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.

* fix(ios): answer runner liveness through the runner provider seam

The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.

* perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.

* fix(ios): a registered runner session counts as live only once it has answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.

* refactor(ios): lift the bridge launch grace out of the snapshot route capture

* test(descriptors): pin the snapshot, diff, and find step-use selectors

* test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.

* fix(plan): count only required operations and read find and snapshot steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.

* perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.

* docs(context): keep the runner-demand vocabulary within the guidance budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.

* refactor(contracts): name the runtime operation vocabulary below the operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.

* fix(apple): reach runner liveness through the memoized operations loader

Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.

* fix(ios): keep bridge-only behavior to iOS Simulators

The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.

* bench(ios): add a first-interaction cell to the snapshot convergence harness

An open that defers runner readiness moves its cost to the first
runner-dependent command. The cell starts each sample like cold, opens the
fixture untimed, then times the first press that follows (the deep-link
confirmation when the launch URL raises it, otherwise the screen anchor).

* bench(ios): read the deep-link confirmation from a snapshot and by node type

The open response carries no tree and regular snapshots publish the node type,
so the confirmation iOS raises for a launch URL was never seen on this runtime
and every deep-linked cell failed its anchor check.

* refactor(plan): keep the step-use selectors inside the registry

The eager-closure ratchet counts every module the registry loads; the
selectors need nothing the registry does not already import, so they live
beside find's recording-effect reader instead of adding a module to every
entry that loads the registry.

* feat(apple): release a speculative runner when the plan is proven observation-only

#2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a
prewarm started that no command has used yet is speculative: the session records that mark at
creation, the first command that is not a readiness probe clears it, and a Simulator open whose
plan is proven observation-only asks the runner owner to release a speculative session in the
background, so the observation path never waits for a runner to stop either. A runner that has
served a command is the session's working runner and stays under the existing idle-stop policy,
so a mixed workload does not pay a cold runner start at every observation-only open.

The release goes through the runner provider seam: the local provider stops its own speculative
session; a provider that never starts speculative work omits the operation and releases nothing.

* bench(ios): press an unambiguous target on the catalog and Settings screens

The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS
Settings screens names two actionable elements (the native tab and the screen title); the CLI
refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything.
Each such screen now names the element the cell presses.

* fix(ios): keep observation on the bridge while app discovery is pending and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.

* fix(apple): queue a speculative-runner release behind a start that is still in flight

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
2026-09-07 10:12:58 +02:00
Thiago Brezinski 5ba4ac707e test(android): reveal smoke canaries by visibility (#2369) 2026-09-07 08:02:59 +02:00
Michał Pierzchała 51ed6217cc refactor(daemon): relocate the daemon client out of src/daemon (#2360)
* refactor(daemon): extract the repair-tombstone reader below store and client

`findUnrecoveredRepairCommitFailure` reads session artifacts off disk and is
reached from the daemon client, which had to import `session-store.ts` — the
daemon's largest server module — for it. Move the tombstone shape, its file
reader and the unrecovered-commit scan into `session-repair-tombstone.ts`, a
leaf below both, and give the tombstone file name a single owner.

No behavior change; both consumers keep their existing tests.

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

* refactor(daemon): relocate the daemon client out of src/daemon

`src/daemon/client/` is the daemon's client, not the daemon: no daemon file
imports it, and its consumers are the CLI, the Node client, the proxy command
and the injected dispatch type. Move it to `src/daemon-client/` as renames so
`src/daemon` is server code plus the shared kernel the client still needs —
`config.ts`, `daemon-process.ts`, `request-progress-protocol.ts`,
`daemon-request.ts` and the extracted `session-repair-tombstone.ts`.

Zone name and rank are unchanged (`daemon-client`, 5); the zone now falls out
of the folder instead of a `src/daemon/client/` prefix. Tests move unchanged.

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

* refactor(daemon): move the session artifact path helpers out of session-store

`src/cli.ts` and `src/remote/remote-request-diagnostics.ts` reach into
`session-store.ts` for one pure path function, `resolveRemoteRequestDiagnosticsPath`,
which made every CLI process eagerly evaluate the daemon's session store and
its whole subtree — the script writer, the event log, the action recorder and
the replay transaction vocabulary.

The four artifact path helpers name files; they hold no store state. Move them
to `src/daemon/session-artifact-paths.ts`, a leaf over `session-paths.ts`, and
point all ten consumers at it. `src/cli.ts`'s eager closure drops from 379
modules to 365 and no longer contains `session-store.ts`; the store itself is
464 -> 341 lines. AGENTS.md's declaration-site pointer follows.

No behavior change: the helpers are unmodified.

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

* chore(gates): re-key the daemon-client gate paths onto src/daemon-client

Path-keyed enforcement follows the relocated files: the fallow health baseline
entries, the oxlint per-file override, the wire-compat surface/ledger/mutation
paths, and the layering zone derivation (the `src/daemon/client/` prefix is
dead now that the folder itself names the zone).

R10's external daemon request/session-state importer list gains the five client
modules. The edges are unchanged by this PR — the client has always built
`DaemonRequest` and read `DaemonResponse`; it sat inside `src/daemon/` and so
fell under the prefix skip. Naming the files keeps the dependency enumerated
and shrink-only, so a new `src/daemon-client/` module reaching `session-state`
still fails. Its size assertion now reads the recorded list instead of a
literal.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 07:49:19 +02:00
Michał Pierzchała 2ec4e91b11 refactor(core): move the command descriptor registry into its own workspace package (#2348)
* refactor(core): move the command descriptor registry into its own package

`src/core/command-descriptor/`, `src/command-catalog.ts`, `src/core/wait-positionals.ts`
and `src/core/parse-timeout.ts` move as git renames into a new private package
`@agent-device/command-registry` (deps: contracts, selectors). One subpath per module
points straight at the moved file; no `index.ts`, no re-export at the old path. Every
consumer switches to the owning specifier.

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

* test(host-kit): pin the command-registry package inside the daemon code graph

The daemon reaches the registry and its catalog only by workspace specifier. A walk
that stopped at the package boundary would report an unchanged signature after a
descriptor edit, and the client would keep reusing a daemon running the superseded
policy. The manifest is asserted beside the sources because its `exports` map is what
chose them. The cache doc comment quoting the old ~800-module graph is corrected.

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

* chore(gates): point the descriptor-registry gates at the package path

R66's `COMMAND_DESCRIPTOR_MODULE`, R16's record-runtime join subject and the Fallow
`AssertTrue` totality-guard key follow the registry to its package. The two descriptor
hubs leave `HUB_ENTRY_FILES` because the package manifest now publishes them, so the
eager-closure gate discovers them as facades and one entry gets one rule; this also
flips `denyPlatformImplementations` from false (hub) to true (package entry) for both,
which is intentional and stricter. `command-registry` joins the ranked spine at rank 1.

No `APPROVED_OVER_CEILING` row: rename detection carries every moved entry's merge-base
baseline, so all twelve fall under the no-growth rule rather than a ceiling.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała ff59309415 test(android-e2e): record rotation state and logcat rotation decisions on a failed step (#2350)
* test(android-e2e): record rotation state and logcat rotation decisions on a failed step

The Android smoke has failed on the post-alert canary since 2026-09-03,
and the failed-step screenshot from run 34021894996 shows why the reads
miss: the device is in landscape at that point, with the canary below
the fold, although `orientation portrait` had taken effect (the fixture
confirmed it and every tap before the alert landed at x=540). Nothing we
keep says what rotated it. A failed step now also writes
failed-step-N-device.txt with the two rotation settings, the display's
rotation lines, and WindowManager's rotation decisions from logcat, read
through adb so they stand even when the CLI path failed.

* test(android-e2e): keep the rotation evidence to WindowManager decisions and display rotation fields

* test(e2e): own failed-step evidence in one collector, bound the device probes, test it

Review follow-up on the rotation evidence. The collectors move out of
the harness closure into failed-step-evidence.ts (fallow complexity),
where the platform hook runs alongside the screenshot and snapshot and
is bounded as a group (15s) so it can never delay them; a hook that
throws, answers nothing, or never answers records nothing for the device
file and leaves the CLI evidence in place. The Android probes get a 5s
per-command bound, and logcat lines are capped in count and length.
Deterministic tests cover the file contents, the hook failure and
timeout cases, and the harness naming every evidence file, device file
included, in failed-step.txt.
2026-09-06 12:46:27 +02:00
Michał Pierzchała dcd8b65d4c refactor(daemon): split src/daemon/types.ts into request types and session state (#2346)
* refactor(daemon): split daemon/types.ts into request and session-state modules

`src/daemon/types.ts` served two audiences from one file: the dispatch request
shape and the daemon's live session record. It also sat in the only daemon type
cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState`
back — so neither file could be read in isolation.

Three modules replace it, each importing only downward:

- `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request
  with no `internal` key and no property path to `SessionState` or `DeviceLease`,
  so a consumer can read a request's command, flags and public metadata without
  depending on the session record.
- `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which
  stays unexported) plus the response vocabulary.
- `session-state.ts` owns `SessionState` and the shapes only it holds.

The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and
`session-state.ts`: it owns the frame VALUE (the class stays unexported, so the
type remains nominal and unconstructible from outside), while `ref-frame.ts`
keeps every lifetime transition and every `session.refFrame` write.

No behavior change: every importer moves to the module owning the symbol it
uses, with no re-export shim at the old path. `client-normalizers.ts` takes
`SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it.

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

* test(daemon): assert the wire request shape cannot reach session state

A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an
`internal` key or grows a property path back to `SessionState` or `DeviceLease`.
Positive controls over `DaemonRequest` prove the walk finds both when they are
there, so a walk that never matches anything cannot pass by accident.

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

* test(daemon): keep the three over-budget test files at their base length

Splitting `daemon/types.ts` turns one combined import into two in every file
that used both halves. Three of those test files are already over the 1,000-line
tripwire, where the size ratchet allows no growth, so each sheds one line that
was carrying nothing:

- `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength`
  assertion an adjacent `toEqual` on an explicit array literal already makes.
- `session-replay-repair-transaction.test.ts` names the filtered close actions
  instead of wrapping the expression across three lines inside `expect`.

No assertion is weakened and no test content is removed. Splitting these files
along the modules they mirror is the standing remedy, but none of those modules
split here, so it stays out of this change and is tracked in #2353.

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

* chore(gates): point the daemon modularity and wire-compat gates at the split modules

R7 now locates the `SessionState` declaration by the declaration itself rather
than by a recorded path: `sessionStateWritePressure` measures the merge-base
tree too, and that tree still declares it in `daemon/types.ts` — a path constant
would measure it as zero pressure and bank the headroom.

R10's external-importer ratchet covers all three modules that replaced
`daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to
a new outside zone. The recorded membership is unchanged: `client-normalizers.ts`
and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only.

The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and
`DaemonArtifact` by path, so those three keys follow the declarations to
`daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the
server-side narrowing of the kernel declaration that fixes the wire shape —
rather than calling it a re-export alias.

The `live-state-shape` and session-resource declaration sites move with
`SessionState`; the depgraph lookalike fixture takes a new plausible path now
that `daemon/session-state.ts` is the real root.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:36:54 +02:00
Michał Pierzchała 6e22e266d7 refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon (#2322)
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon

Move the pure wire vocabulary (base path, header names, URL/auth/tenant
builders, /health payload) from src/daemon into @agent-device/contracts as
the daemon-http subpath, so src/remote and src/cli stop importing daemon
server internals. buildDaemonHealthPayload takes the version its caller
advertises (R18 keeps host mechanics out of contracts); both callers pass
readVersion(). Wire-compat surface, mutation, and ledger references follow
the package path.

* chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves

Exports map + snapshot gain the daemon-http subpath. The wire ledger
re-keys the eight moved declarations (buildDaemonHealthPayload moves with
its new caller-supplied version parameter, acked additive). The
released-baseline comparison now classifies a baseline declaration that
re-appears unchanged at exactly one new path as a move instead of a
removal: a file move is not wire surface a released peer stopped sending.
A move that changes shape is a change acked at the destination path, and a
name still owned by the baseline stays a removal.
2026-09-06 08:20:39 +02:00
Michał Pierzchała 0bebbe8dc2 feat: admit managed requests within lease authority (#2319)
* feat(daemon): admit managed requests within lease authority

* test: verify managed request authority and activation boundaries

* test: run managed request admission in provider integration
2026-09-06 08:10:59 +02:00
Michał Pierzchała ebdaa7617e feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority

* fix: preserve lazy simulator readiness through scoped authority

* fix: admit managed operations at their dispatch boundary

* test: move managed automation scenarios to integration lane

* chore(gates): declare the private managed readiness scope export
2026-09-06 08:10:59 +02:00
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

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

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

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

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

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

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

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

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

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

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00
Michał Pierzchała cf83afb9c9 feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge

* fix(ios): preserve snapshot fallback lineage

* fix(ios): keep regular depth in presentation

* perf(ios): reuse process-verified snapshot targets

* fix(ios): refuse snapshots beneath another foreground owner

* test(ios): bound native setup and isolate runner reset

* test(ios): synchronize helper crashes with request dispatch

* test(ios): exercise foreground guards through native capture

* chore(gates): run native snapshot ownership regression on iOS CI
2026-09-05 23:05:58 +02:00
Michał Pierzchała 2dabe29b14 fix(web): preserve the backend ref so snapshot refs match actionable refs (#2283)
* fix(web): preserve the backend ref so snapshot refs match actionable refs

The web/agent-browser backend mints refs in tree order and skips
non-interactive nodes, so its `@eN` refs are not dense. agent-device was
dropping that ref in `normalizeAgentBrowserSnapshot` and then re-minting a
dense positional `e${index+1}` in `attachRefs`. The ref an agent reads off
the snapshot (dense, positional) therefore did not equal the ref the backend
resolves on the next action (tree-ordered). On the ShopDemo login screen the
username textbox displayed as one ref while the backend's ref for the same
position pointed at the passcode field, so `fill @e3` landed in the wrong
input.

Preserve the backend ref on each web node and make `attachRefs` keep a
node's existing `ref` when present, falling back to dense numbering for
backends that do not mint refs (iOS/Android/maestro are unaffected).

* test(web): cover non-dense refs through routed actions
2026-09-05 21:06:54 +02:00
Michał Pierzchała 35362fe517 refactor(cli): let help resolve command aliases itself and retire R12 (#2293)
* refactor(cli): let cli-help resolve the --help alias itself

bin.ts's --help fast path composed
buildCommandUsageText(normalizeCliCommandAlias(helpTarget)) inline, which
let a future edit call buildCommandUsageText raw without anyone noticing
until an alias's help silently dropped back to a full CLI bootstrap (the
regression #1641 fixed). Move the composition into cli-schema/cli-help.ts
as resolveHelpTargetUsageText, so bin.ts just calls one function that owns
its own alias normalization; bin.ts no longer imports the alias registry
at all.

Retargets cli-help-alias-fast-path.test.ts at the new function (same three
cases) and adds a process-level smoke test asserting `tap --help`/`launch
--help` stdout is byte-identical to `press --help`/`open --help`. Seen red
by temporarily removing the `tap` alias from CLI_COMMAND_ALIASES (both
fast and slow paths lose the alias, producing an "Unknown command: tap"
mismatch); green again after restoring it.

Verified manually: `node --experimental-strip-types src/bin.ts tap --help`
stays byte-identical to `press --help`, and `launch --help` to `open
--help`; `rotate --help` still falls through to the retired-command error.

* chore(gates): retire R12 now that cli-help owns its own alias resolution

bin.ts can no longer compose buildCommandUsageText and
normalizeCliCommandAlias incorrectly because it doesn't hold either import
any more — resolveHelpTargetUsageText in cli-schema/cli-help.ts is the only
call site, and cli-help-alias-fast-path.test.ts plus the new smoke-cli
process test pin it. The static R12 checker existed only to prove that
composition from source text; delete it along with its rule wiring in
check.ts (rule function, import, LAYERING_RULE_IDS/LAYERING_RULES entries,
header comment, summary string).

Drops scripts/layering/bin-alias-fast-path.ts (352 lines) and its test
(311 lines). Updates the two stale references left behind:
record-runtime-mechanics-policy.ts's comparison to R12's "delegate to your
single owner" shape, and check-wiring.test.ts's header, which named
bin-alias-fast-path.test.ts as the seam it protects.

rule-ids.ts discovers rule ids by scanning source text rather than a
hand-maintained list, so no entry there needed updating.

Verified: pnpm check:layering green (175/175), including
check-wiring.test.ts and rule-ids.test.ts; pnpm check:quick (lint +
typecheck) clean; scripts/__tests__/eager-closure-budgets.test.ts
(418/418) unaffected, since neither bin.ts nor cli-help.ts sits in any
HUB_ENTRY_FILES or facade closure — both files reach cli-help.ts only
through a dynamic import.

* test(cli): pin the alias help fast path with a coverage-based oracle

The byte-identical stdout test cannot fail when the fast path is bypassed:
src/cli.ts's slow path resolves the same alias and writes the identical
string, so a reintroduced hand-written table in bin.ts (the exact shape of
#1641) would still pass it. Add a second process-level test that runs
`tap`/`launch --help` and `rotate --help` with NODE_V8_COVERAGE set and reads
the subprocess's own coverage report for src/cli/process-entry.ts, the one
module runCli's slow path loads and the fast path never does.

Seen red: forcing the fast path to always fall through to runCli (simulating
the reintroduced-table bug) failed this test (bootstrappedFullCli true where
false was expected) while the byte-identical test stayed green; reverted and
confirmed both green.

* test(cli): restore an independent oracle for alias help parity

The canonical side of "alias help output matches its canonical command" also
called resolveHelpTargetUsageText, so the assertion became self-consistency:
a degenerate normalizer that maps every input to one canonical command would
make aliasHelp and canonicalHelp equal for every case. Compare
resolveHelpTargetUsageText(alias) against buildCommandUsageText(canonical)
(no alias normalization on the canonical side) instead, restoring the
original two-source oracle.

Seen red: pointing resolveHelpTargetUsageText at a degenerate
`return buildCommandUsageText('press')` failed this test
("launch --help" no longer byte-identical to "open --help"); reverted and
confirmed green.

* refactor(mcp): route the help tool through resolveHelpTargetUsageText

server-guide.ts's help tool composed
buildCommandUsageText(normalizeCliCommandAlias(topic)) inline, the same
composition bin.ts held before this PR moved it into cli-help.ts. That left
a second hand-written call site the R12 gate's own kill criterion said had
to be gone before retirement was moot. Call resolveHelpTargetUsageText(topic)
instead; behavior is unchanged (manually confirmed tap/press and rotate
topics still match) since it's the same composition, and no closure/layering
change since server-guide.ts already imports cli-help.ts statically.

* style: apply oxfmt

* test(cli): prove the help fast path for every registered alias

* refactor(cli): make the process entry importable and test it directly

bin.ts ran its dispatch at import time, so the only way to prove that an
alias --help never loads the full CLI was to spawn the process under
NODE_V8_COVERAGE and grep the report for process-entry.ts. That oracle
needed a paragraph to justify; the code was wrong, not the comment.

The dispatch now lives in src/cli/entry.ts as runEntry(argv, modules, io),
with the five lazy imports injected by bin.ts. entry.test.ts drives it with
recording loaders and the real help module: every registry alias prints its
canonical help with only the help module loaded, an unknown topic falls
through to the CLI loader, --version, bare usage, mcp, and startup failures
each have one case. The subprocess coverage machinery, the alias table pin,
and the multi-line comments are gone; the smoke test keeps one
registry-derived byte-identical alias --help check against the real bin.ts.

Seen red: hand-routing long-press and relaunch to the CLI loader inside
entry.ts failed "every registered alias prints its canonical help without
loading the CLI"; restored.
2026-09-05 20:01:21 +02:00
Michał Pierzchała 7bf8d8c4a3 fix(remote): materialize test suite artifacts against a remote daemon (#2272)
* fix(remote): materialize test suite artifacts against a remote daemon (#2246)

`agent-device test` crashed with ENOENT against a remote daemon because the
scheduler resolved `--artifacts-dir` against the caller's `cwd`, sent over the
wire, on the daemon's own filesystem. Mirrors #1802's read-side fix for the
same command: the CLI now redirects `--artifacts-dir` to a temp directory the
daemon owns before the suite runs, and the daemon rewrites every artifact path
in its response back to the caller-local root and registers the suite
directory as one downloadable artifact through the existing screenshot/record
transport, extended here to also support directories via the codebase's
existing safe archive extractor (the archive comes from a remote daemon, a
different trust domain, so a raw `tar` invocation was not enough).

* fix(remote): publish test artifacts atomically

* perf(cli): keep artifact downloads lazy
2026-09-03 20:25:44 +02:00
Michał Pierzchała 4e9820e46e ci(android): wait for the producer's platform job and keep the fallback build alive (#2261)
Classify the fixture producer by its platform job (Android Release / iOS
Release) instead of the run's aggregate status: the run flickers to
"queued" in the gap between its fingerprint job and its platform job,
which made the Android consumer bail into an inline build almost
instantly (run 33550746596). Both queued and in_progress on the
platform job now mean keep waiting.

Raise android.yml's wait-for-artifact-seconds to 1800s (matching the
iOS workflow's own budget) so a real wait can play out now that the
premature "queued" bail is gone.

Raise the Android Gradle daemon heap (org.gradle.jvmargs=-Xmx4g) for
both the producer build and the inline fallback build: the producer's
own Android Release job OOM'd in :app:compileReleaseArtProfile (run
33728318738), and both prior inline fallbacks OOM'd in
:app:mergeDexRelease.
2026-09-03 14:30:57 +02:00
Michał Pierzchała 79391375c6 test(android-e2e): make an alert-dismiss failure self-explaining (#2260)
* test(android-e2e): make an alert-dismiss failure self-explaining

The Android smoke scenario asserted the post-alert canary text with a
screen-wide `wait text`, whose timeout report is a top-6-label surface
dump that may not include the element in question at all. Assert the
canary through the specific automation-alert-result element instead, so
a failure states its actual current value directly.

Also record the tapped alert button's coordinates alongside its label
(already recorded) in the Android alert-handled result, and capture a
screenshot artifact when an e2e `wait` step times out, so a flake has
more to go on than the surface dump.

* review: extract shared alert-test fixtures, trim narrated docblock

- Extract node/text/button RawSnapshotNode builders (near-duplicated
  between alert.test.ts and alert-detection.test.ts) into a sibling
  alert-fixtures.ts, reconciling the two button() signatures by keeping
  the optional `permission` param. Both suites now import from it.
  Planted red: bumped the shared button() rect width and reran both
  suites — 2 of 7 tests failed on the changed tap coordinates
  (alert.test.ts's dismiss/accept cases), confirming the fixtures are
  live-wired; reverted, suite back to 7/7 green.
- Trim captureWaitTimeoutScreenshot's docblock in runtime.ts to the
  caller-facing contract only ("never throws, returns undefined on a
  failed capture"); the surface-dump rationale already lives in the PR
  body's Summary.
2026-09-03 14:30:40 +02:00
Michał Pierzchała ed76c9c848 fix(ci): fingerprint the test app from its own directory (#2256)
@expo/fingerprint hashes process.cwd() as the project root, but
resolve-artifact-name.sh invoked it from the workspace root: the fixture
cache key was the whole repo's fingerprint, not examples/test-app's native
sources. Run fingerprint:generate with cwd examples/test-app instead; the
script's stdout contract (fingerprint.<hash>.<platform>) is unchanged.
2026-09-03 11:19:28 +02:00
Michał Pierzchała 2371ba9bff feat: add strict native absence assertion (#2245)
* feat: add strict native absence assertion

* fix: address absence assertion review feedback
2026-09-03 08:02:09 +02:00
Michał Pierzchała 7ee1a5ded7 refactor(ios): carry provider acquisitions through one presentation owner (#2233)
* refactor(ios): centralize provider snapshot presentation

* fix(ios): close provider snapshot ownership gaps

* fix(ios): enforce provider snapshot ownership boundary

* fix(capture-kit): preserve snapshot engine lazy closure
2026-09-02 14:06:33 +02:00
Michał Pierzchała 947582a3cc refactor(daemon): move interaction and find routes behind facade (#2178) (#2228) 2026-09-02 07:59:06 +02:00