mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
main
812
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0e6862cbcc |
fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials Six failed-load screens share one Retry shape: re-dial a host that is not connected, otherwise re-read. On the page the re-dial is inert (`client-context.web.tsx:55`) and each screen's load already re-runs when the shell's client reconnects, so in the disconnected state that Retry did nothing at all. `connectionRetryAction` makes the decision once and answers null when a re-dial is needed and none exists; agent history, the file explorer root, the file preview, git history, the source-control status gate and the diff review render no Retry for null. The explorer's per-folder Retry keeps its control: it queues the folder, and the queue drains on the next `connected` whoever brought it back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page offers no re-dial, so no header offers one `forceReconnect` on the page was `() => Promise.resolve()`: the shell owns the connection and nothing in the document can re-dial it. The host header's Reconnect and the session header's "tap to retry" were wired to it and did nothing there. The context member is now nullable and the page's provider hands out null, so the compiler found every caller: both headers render no reconnect affordance for null, and the session status keeps the verdict label without promising a tap. Native providers and the recording adapters still pass a function, so nothing a phone renders changes. The session route's host-JSX parity hash moves for the header's extra null check; the page test doubles that stubbed the old inert re-dial now stub null. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the auth-failed banner cites the page's re-dial as null Three comments and the banner's override reason still said the page's `forceReconnect` was an inert `() => Promise.resolve()`, and cited `client-context.web.tsx` lines the previous commit moved. They now say null and point at the lines that hold it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session closure gains the page's Retry decision `connection-retry-action.ts` is the one module the Retry fix adds to the session route's page closure, reached through the explorer, source control and git history it docks. Measured on this head with all five generators run first, and diffed against the pre-change closure: one local module added, none removed. Session route closure 4207 -> 4208 modules, local 1021 -> 1022. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the capability probe belongs on the page, and says why The push fence excluded `runtime-capability-probe.ts` because the session route and the host screen run it. The session half holds, and the probe works there: `status.get` carries no client identity and makes no write, the shell forwards it like any non-`native.` request, and the desktop's mobile allowlist admits it. The host-screen half no longer does: `codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which the bundle carries and the page hands to the native screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the session retry test's cast under its disable line The formatter wrapped the cast onto the line after the disable comment, which left it uncovered. The cast now sits on its own line directly below the SAFETY note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the agent-history Retry test mocks the pathname the handoff reads Main's page route handoff now subscribes to `usePathname` (#22300), and the Retry suite this branch added mounts that handoff with an `expo-router` mock that lacked it. Same one-line addition main made to the back-handoff suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the reload each hidden page Retry relies on Hiding a Retry on the page rests on the screen's load re-running when the shell's client reconnects, because nothing on the page re-dials. Only the explorer's folder drain pinned that. Each other site now has a case that starts unreachable with no Retry and asserts the load goes out on the client and state the reconnect delivers: agent history (status.get), file preview (the preview read), diff review (the snapshot load), git history (git.history) and source-control status (git.status, in the loaders suite because the panel test mocks the state hook). Each goes red when the `client`/`connState` dependencies it guards are removed; for source control that is both `loadStatus` and the `loadBranchCompare` it depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): one import of the transport types in the source-control loaders test CI's native code-quality audit denies the duplicate-import warning the reload pin added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7240368726 |
feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived
The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): record why a hybrid shell update failed, on the device
A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): forget a removed host's recorded update failures
Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show recorded update failures in Troubleshoot
A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the update-failure row doubles without casts
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forget a host's update failures once a newer generation commits
The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the session closure for the shared journal producer #22299 added
main at
|
||
|
|
11db2b9a7d |
feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
564f135248 |
fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition The emulator's page-only defect, in a browser: an Android soft keyboard keeps a composition open over the word being typed, so the Enter keydown carries `isComposing: true`, which is the condition react-native-web reads to skip `onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the text. The probe route now mounts `useTerminalLiveInputCommit` and the command dock's own field props, so keys enter through the browser rather than through a handle that calls the hook directly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): format the live-input render check Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page's live input submits under an open composition react-native-web's keydown handler withholds `onSubmitEditing` whenever the Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android `keyCode` 229 that stands for it — which is a soft keyboard's normal state mid-word. Nothing reached the terminal and the field kept the text. Native Android's editor action has no such suppression, which is why only the page showed it. The field now also claims `beforeinput`/`insertLineBreak`, the browser's own end-of-line signal. react-native-web cancels every keydown it does submit on, so that event exists only in the cases it dropped, never twice; an IME still choosing a candidate reports `insertCompositionText` and is left alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): list the live-input submit binding, and repin the session closure The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists exactly the overrides on disk. The session route's page closure moves with it: the callback ref and the binding it resolves to are both local, and the native sibling stays out, which is what the pair is for. modules 4207 -> 4209 (+2) local modules 1021 -> 1023 (+2) Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the key bar's Enter chip leaves the sent text in the live field The device trace's variant (a), which is what shots/23 was: the chip emits no DOM key event, so react-native-web's submit handling never runs and the accessory hook is the only thing that could end the field's editing session. With nothing held it takes the send-now branch, which flushes nothing and writes neither the capture state nor the field, so the text the PTY already echoed stays put and the next keystrokes append to it. Not a page defect: the held-text fallback only holds a trailing non-ASCII run, so ASCII leaves nothing held on native either. The unit case is on the shared hook for that reason. The probe route now models what the send actions do with 'allow-raw', so the check can see a control sent twice or not at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the buffered command field has the live field's Enter gate too Three cases, one red. A plain Enter sends the draft and empties the field, which is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's Enter chip in buffered mode is a plain terminal key: the accessory hook declines at its live-handle guard, one carriage return goes out and the draft is untouched, also a guard. The red one is Enter under an open composition. This field reaches its send through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it exactly as it did for the live field, and the draft neither goes out nor leaves the field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): an accessory Enter ends the field's editing session, on both fields Two defects, one rule: after a control that ends the line, the terminal owns the echoed text and the field's editing session is over. The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so only the accessory hook could end that session. Its held-text branch does, through the flush; with nothing held it took `send-now`, which flushed nothing and wrote neither the capture state nor the field. Not page-only: the held-text fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native either. `send-now` now takes the same flush when the bytes end the line, and still defers the send to its caller so exactly one return goes out. The buffered command field had the live field's composition gate, because it also reaches its send through `onSubmitEditing` alone. It binds the page's line-break signal now too, which is why the seam is named for a terminal text field rather than for the live input. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the submit binding holds the handler its first render was given Found by pullfrog on #22300. The binding refreshed its handler ref only when the callback identity changed, so a caller memoizing on an empty dependency list was bound once and never again. The buffered command field does exactly that: its submit closes over handleSend, a per-render function whose guard reads client and activeHandle, both null until effects supply them, so the page's line-break submit could never pass that guard. The probe route now carries the same two paths the dock has — a fresh per-render function on the field's onSubmitEditing prop, and the memoized closure on the binding — because the working prop path is what hid this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the buffered field's page submit reaches a live handleSend Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty dependency list, which froze the per-render `handleSend` it calls. That guard reads `client`, `activeHandle` and `canSend`, none of which the first render has, so the page's line-break submit could never pass it. The field's own `onSubmitEditing` prop kept working, which is what hid it. The handler is per-render now, and the binding refreshes its handler ref on every commit rather than when the callback identity changes — the ref exists so the listener always reaches the newest handler, and it should not rest on a caller's memoization. That second half fixes nothing on its own: a `useCallback` with `[]` returns one function object for the life of the component, so no ref can find a newer closure behind it. The source census is what catches that, and it is red against the frozen handler. The probe route is back on the dock's shape, per-render on both submit paths, with a note saying why a route that writes its own submit cannot catch this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's buffered submit reaches a handleSend that can send The census beside this reads one spelling of a frozen handler. This reads the behaviour: the send-actions hook is rendered first as a session is before its effects land, with no client and no handle, then again as it is after, and the listener the page's binding attached has to reach the second one. Asserted on the params that reach the client, not on a call count. Red with the `useCallback` restored, and red with a `useMemo` in its place, which is the point of testing the behaviour rather than the spelling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): declare the two mock arrays instead of asserting them `[] as Array<T>` inside the hoisted factory was a type assertion with nothing to explain: the arrays are built here, so a checked declaration says the same thing and the quality gate has nothing to flag. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the submit binding stops claiming a cure it does not have The handler ref refreshed on every commit rather than on the callback identity, and the docblock called that the reason the seam exists. It is not: a caller that freezes its closure hands this hook one function object for the life of the component, so no ref finds a newer one, and a caller that does not freeze it changes identity every render and refreshes the dependency anyway. Measured both ways. The dependency is back, and the prose says only what the code does. The rule that does hold — a bound handler must not be frozen on an empty dependency list — is stated where it is enforced, in the wiring census, with the behavioural check named beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the submit binding's latest-ref drops its dependency list React Doctor flags the dependency twice, once per bound field: both of the dock's field submits are rebuilt every render, because the handleSend they read is, so `[onSubmit]` is a new value every time and there is nothing to compare. The tree's other latest-refs are written without a list for the same reason — use-mobile-web-shell-bridge.ts:148 is the one this follows. The comment says what the ref does, which is mirror the newest closure after each commit so callers may pass per-render handlers. It claims nothing about a caller that freezes one; that rule is still the wiring census's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a8786c040d |
fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host The page holds one host profile from `init.host` and no credential, so `removeHost` on web resolved without doing anything and the screen reported success for a host that was still paired. Its `.web` sibling refuses with a typed error instead, and the auth-failed banner's Remove — the one surface that opens the confirm — is absent on the page, because a control that can only refuse should not be there. Refusing is also the fence that keeps `push-registration.ts` out of the page bundle: the native lifecycle file's import was that subsystem's only path into a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the push families the page closure no longer reaches `host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a page route, so the whole directory left the page bundle and the capability probe left the C1 layout closure with it. The derived family set shrank by two; the pin tables and their counts now match what the closure reaches. The expo-notifications fence grows a second claim and loses a precondition that had become false: `push-token.web.ts` and `desktop-notification-channel.web.ts` are no longer in the bundle either, so the fence is stated as the absence of the directory. C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125. Session route closure 4211 -> 4207 modules. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page's auth-failed banner offers no control it can honour The banner is reachable on the page — the shell forwards the native client's state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum (`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three actions. `forceReconnect` is `() => Promise.resolve()` there (`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`), `/pair-scan` sits outside the page's route root of `app/h` (`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous commit hid only Remove and claimed the other two still worked; they do not. The whole action row moves into `AuthFailedBannerActions`, whose `.web` sibling renders no control and one line naming the app. The sentence above it is unchanged: re-pairing from the desktop is still what to do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3bb9a4e261 |
fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9da7f7edfc |
test(config): the session render rig waits for the reads it asserts (#22269)
The session route issues worktree.show, session.tabs.list and terminal.list from effects that run after the commit painting 'Terminal', so reading __orcaRenderCheckRequests where openRoute resolves is a snapshot taken before the burst. Under a 20x CPU throttle none of the three is in it, which is the shape the loaded CI job hit twice. waitForRecordedRequests polls the double's own log under a 30s bound and names what never arrived. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ba742a86bb |
fix(linux): release orphaned processes when their owner exits (#22247)
* fix(linux): release orphaned processes when their owner exits * fix(linux): handle inhibitor errors until streams close --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
0ab2ba3480 |
fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record `host-status-gates.ts` runs above every page route, and a readable `status.get` had it write `orca:host-app-version:v1:<hostId>` through `host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's adapter and that key is not one `page-storage-keys.ts` hands a route, so every mount posted a write the shell refused and logged as `storage-write-dropped`. Not admitted through the storage seam, because the page never reads it back: the record's only reader is the native troubleshoot screen's `native-diagnostics-operations.ts`, which is not in the page's bundle. A `.web` sibling keeps no record instead. The bounds check moves to `host-app-version.ts` so both hosts read a reported version the same way. The session render check now collects warnings as well as errors and answers `status.get`, which is what arms the write: the other cases' double answers no RPC, so the drop needed a reply rather than a control. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page does not import expo-notifications `DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token listener React Native Web answers with a warning, and it reads the persisted server registration out of `window.localStorage` behind a `typeof localStorage === 'undefined'` guard. The Android shell's WebView has DOM storage off, where `window.localStorage` is `null` rather than undefined, so the guard passed and the read raised "Cannot read properties of null (reading 'getItem')" at error level on every page load. Two modules imported the package — `push-token.ts` and `desktop-notification-channel.ts`, both reached through `push-registration.ts`, which the host layout pulls in via the host screen's remove action. Both get a `.web` sibling. The page holds no device push token and creates no Android channel; push registration needs a token the shell owns and a gateway the page has no client for. Every call in those two files was already inert on web, so a page that imports one behaves correctly and still loads the package: the closure check beside them is what keeps a third importer out. The session render check adds the device's own shape — `localStorage` reading `null` — and reds on the error the emulator saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page declares an icon, so no browser asks for one With none declared a browser asks the origin for /favicon.ico on its own, and the shell's asset server answers 403 because the path is in no manifest — which the emulator run saw repeatedly. The document now carries `<link rel="icon" href="data:," />`, a browser's own way of being told there is no icon. An empty data URI rather than an asset: the page is a WebView document with no tab to put an icon in, and the bundle's images are content-hashed route assets whose names change with their bytes. `img-src 'self' data: https:` already admits the scheme. Two assertions, because each is blind where the other sees. The build check reads the document and runs everywhere. The session render check reads the request, which only a full Chrome makes — `ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the server's own log: a favicon fetch comes from the browser process rather than the page, so Playwright's request events never report one. It also settles on network idle first, because the fetch comes after the text the route waited on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the page-noise comments to the bar Comment-only. The three `.web` siblings, the document's icon line and the three override reasons each said their cause once and then said it again; each now states what the page keeps and why, once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the session closure after expo-notifications left Measured on this head with all five generators run first, against a scratch worktree detached at the base, which reads the committed pin exactly: 4271 modules and 1023 local. modules 4271 -> 4210 (-61) local modules 1023 -> 1024 (+1) 65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications' own 55, and expo-application, badgin, abort-controller and event-target-shim behind them. The other three are the native files the `.web` siblings replace, so the siblings cost the local count nothing and its +1 is `host-app-version.ts`, the one new module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): count the packages the closure note names The note said 62 vendored modules left and then named five packages without counts, so the names read as the whole of the 62 and summed to five. Each carries its own count now: 55 + 3 + 2 + 1 + 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b76bc79d73 |
build(mobile): key Metro's transform cache on the shell build kind (#22244)
`babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform time (babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes into the transform cache key carries that value: the key is `metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the Metro version, `cacheVersion`, the transformer path and `@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`, none of which reads the environment. A release assembled after an opposite-kind build reuses the warm entries and bakes the wrong shell, and the absence of the variable's name in the bundle cannot tell the two apart. The newest published `@expo/metro-config` (58.0.4) keys it no differently. Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app applies, keeping Metro's own version as the prefix. Proven with four `expo export --platform android` runs against an isolated Metro cache. Before: `ota` then `native` produced byte-identical bundles, both `return 'ota'`. After: the second run returns `'native'` under a different bundle hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
632ae1320b |
fix(daemon): reap terminal descendants during shutdown (#22232)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
59c0d5585e |
fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init
The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.
The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): the shell swaps the page's client id for the device's
The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id
|
||
|
|
da1c322b00 |
feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in preferences.ts. Expo's babel preset inlines a literal process.env member expression at build time, so a release bundle carries the answer as a constant and anything but the exact string 'ota' — unset, empty, a typo — is native. Every default build is therefore the native app, unchanged. mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to mount the page comes from the build and never from storage: a native binary installed over an OTA one, same bundle id and same data container, still refuses a stored 'true' without reading the key. An unset key reads on only in an OTA build; a development build keeps its opt-in, and a stored 'false' wins everywhere so the Troubleshoot toggle can switch an OTA build back to native. That toggle now mounts wherever the flag can be on, which is the only way back to the native screens in an OTA build, and its label names the build kind rather than saying "(dev)". The bundle probe row beside it stays development-only: it fetches. The flag census gains two rules — one module reads the switch, in the member form Expo inlines and not the bracket form, and one named function answers the build kind — and the build-kind fence now lists the Troubleshoot route that asks it. Docblocks that said a store build can never mount the shell now say it mounts only when built for OTA. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): one workflow input picks the shell, and no input means native Both release workflows gain a `shell` workflow_dispatch choice, options native and ota, default native, and hand it to the step that bundles the JavaScript as EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and the fastlane build_and_upload step on iOS; nothing else in either file sets it. A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'` yields native for them — the first OTA release is a dispatch with one field changed, and every other run is the app we ship today. Each build step prints the value it is about to build with, read back from the same variable rather than from a second copy of the expression, so a run's log cannot claim a shell the build did not use. The new contract test evaluates that expression rather than matching its text: absent, empty and 'native' all resolve to native, 'ota' to ota, and any expression shape it cannot evaluate is a failure rather than a pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build: the desktop packages the real page, and the placeholder is retired build:mobile-web now runs the app builder and app verifier, and both take their output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each carrying a constant of their own — one definition of where the bundle lives, so a drift cannot leave electron-builder's beforePack looking at an empty directory while the builder reports a tree it wrote elsewhere. build:mobile-web:app is gone; it was the same two commands. src/mobile-web/ and its two scripts go with it. What the app builder shared with them is split into three modules named for what they hold rather than for the bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the canonical asset serialization, buildId, hashed assets, the protocol window and the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two failure modes are Windows paths and symlinked entries), and mobile-web-source-line-endings.mjs (the CRLF guard, now with a required directory rather than a default pointing at the deleted tree). The two suites that only needed *a* valid tree on disk — the beforePack guard and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree instead of bundling the whole mobile graph. It goes through the same manifest writer the page does, so a manifest shape change still reaches them. Also retired: the placeholder's tsconfig project and its typecheck lane, its knip entry, its electron-builder exclusion and .gitattributes pins, and the app-bundle test that asserted the shims stayed out of a builder that no longer exists. pr.yml's page job builds the same bundle the package job ships. Inert for native phones: they never fetch it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): one import of node:fs/promises in the entry-detection suite The changed-code quality gate's focused plugins read the two as a duplicate import; the readFile line was left over from the split. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: the comments that still describe the retired placeholder bundle The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app and shipped by nothing. That script, that directory and that fact are all gone: it is built by `build:mobile-web` into the packaged bundle dir, and a phone mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota. Two Windows cache keys explained themselves by naming src/mobile-web and "the two bundle builders"; config/** now covers the builder, the verifier and the manifest writer, and the spike's key no longer waits on a Phase C flip that has happened. The keys themselves are unchanged. Three scratch directories in the app-bundle suites and one in the verifier still spelled the retired output root. Renamed to mobile-web, which is what the build writes; they are temp subdirectory names and nothing reads them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9a25e318f6 |
fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour On React Native Web a TextInput ref is the DOM node itself, so `setNativeProps` does not exist. The two terminal live-input hooks called it on `liveInputRef.current`, and the session route reaches one of them from a mount effect, so the write was a TypeError that faulted the whole page: the shell tore the view out, the terminal surface stayed 0x0, the IME never opened and the accessory bar did nothing. Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts` sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That covers the case React has no commit to make, which is where an interrupted IME composition leaves the field. The browser oracle is new: the session render check cannot reach this defect, because its shell double answers no RPC, so no terminal handle exists, `liveInputEnabled` is false, the field never mounts and the write is skipped by its own optional chain. The new check mounts both hooks against a real field on the page bundle instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): fence every setNativeProps write with a web sibling Fenced by absence rather than by a list of approved callers: any mobile source module that calls `setNativeProps` must ship a `.web.ts(x)` for the page bundler to resolve in its place, and no web sibling may make the call itself. Run against the two hooks as they were, it names both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the live-input seam's comments to what is not obvious Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the settings scroll lock into a seam, and fence the route tree The census scanned `mobile/src` only, so it read the terminal-settings screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app` too now, under a stricter rule: a route file may not make the call at all, because expo-router registers a `.web.tsx` beside a route as a second route rather than as a platform sibling, so a route cannot own a platform split. The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`, beside the styles module that already owns this screen. Measured rather than assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no `setNativeProps` at all, and drives the scroller's overflow from a generated class with nothing inline, so the sibling locks with an inline `overflowY` and unlocks by clearing it. `overflowY` rather than `touch-action`, which stops a touch drag but leaves the wheel scrolling. The seam does not enter the session route's closure — terminal-settings is not under `app/h` — so the module pin is unmoved at 4270, re-measured. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the absent setNativeProps with `in`, not Reflect.get `in` walks the prototype chain, so the claim that RN Web has no such method to inherit is the same one, and the anti-slop gate has no dynamic read to object to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
6ae5ef2d00 |
fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) `nodePaths` is a fallback esbuild consults only where normal resolution fails, so it never reached the four modules under `src/shared` that the page imports: sitting above `mobile/`, their bare `zod` resolved upward to the root's 4.5.4 while the 58 mobile modules beside them resolved to mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and salvage combinators built by one instance nested inside schemas built by the other. Mobile's copy, because the mobile app already says so: `mobile/tsconfig .json` maps `zod` to `./node_modules/zod`, a shared module joins that program as an imported file, and `--traceResolution` shows tsc holding `zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that disagreed with the app's own compile-time contract. The build drops from 67 scripts to 66 and from 8,055,568 bytes to 7,686,714, nearly all of it before the first route: the entry's static closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is re-measured rather than bumped, and the entry-budget note's static-import readings are re-measured with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the pins the second Zod was inside The session route's module closure and the asset ceiling both counted the root's copy. Both re-measured rather than adjusted to fit. The closure falls 4363 -> 4269. The two module lists were diffed rather than the total inferred: 94 entries gone, every one of them `zod@4.5.4`, none added, because mobile's 79 were already in the closure, and the `local` count holds at 1021 -- this took no source module out of the page, only the second copy of a package. The asset ceiling is derived from the chunk envelope, which the re-measured sweep moved by one, so 30 routes now derive 248 assets and 31 derive 257. The crossing it exists to name is still 31. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): align three readings with the head that measures them All three described a bundle with two Zods in it, and the third was already stale on main. The escape-hatch note said 5 of the 14 routes break the entry budget on their own. Re-measured by making one route's manifest entry a static import and reading the entry's own static closure back, it is one: session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB. Same readings as the note in verify-mobile-web-app-bundle.mjs, which is the thing this case asserts against. The mermaid pair said the bundle "emits 69 scripts". It emits 66, and this head's own fourteen-route prefix reads 64. The pair stays at 69 and 172: what the case pins is that the envelope tells the two apart, not either build's size. Saying so in the comment, with the warning that 69 now sits just under the envelope -- a sweep that falls further fails this on a frozen number, which is a signal to re-measure the pair rather than to raise the ceiling. The assets line said 215 against 112. 215 still derives from the pinned 172; 112 never matched the envelope it claimed, which allowed 114 on main and allows 113 here. The route count is the one number here that has to follow the tree, so it is spelled and pinned to the sweep's own length. The static-import readings are measurements rather than table counts and the mermaid pair must not move at all, so neither is spelled. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cut the mermaid and census comments to their claims Round 2 explained the frozen pair over four paragraphs and the census row over six lines. Both now say what they are for and stop: the pair, why neither number moves, and the one warning that matters. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a932147308 |
test(config): the render rig's navigation wait is bounded, and the frame-budget sweep reads the frames its capture painted (#22177)
* test(config): the render rig's navigation wait is bounded and says why it gave up `waitForRecordedNavigation` ticked every 10 ms until the case's own timeout, so an arm whose click missed its 2 s actionability window waited for a record nobody would write and failed as a bare timeout with the click's error swallowed. Measured on this tree: with the click pointed at a selector that does not exist, both engines failed with `Test timed out in 120000ms` and no mention of the click. C8.1 round 1 saw the same shape at 240 s on CI and dropped a render arm for it. The bound is 10 s, sized from the rig rather than chosen: the four arms that take this path, three runs each on both engines, answered on the loop's first check at 0 ms in all 24 readings, and in 24 more taken while two full `config/scripts` suites ran beside them, where the slowest whole case was 2859 ms. Past it the wait throws naming the arm, how long it waited, what the click did and what the frame last read; the same case now fails in 14.4 s. The happy path is unchanged -- the first check still answers it, with no added wait. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the frame-budget sweep reads the frames its own capture painted The sweep decided which screencast frames carried the noise by counting arrivals after the raster barrier, and frames do not reach the client in capture order. Measured directly against Chromium through CDP at 1400x1600, six captures at 20x CPU throttling: every frame of the black canvas the resize left, and every frame still in flight from the previous viewport, was stamped 86 to 161 ms before this capture's paint and still arrived after the barrier, while every frame carrying the noise was stamped inside 150 ms after it. A black 1400x1600 frame encodes to 13483 bytes, which is the 0.006 bytes/px read on 2026-09-22, and a full frame of a previous smaller viewport is the ~447 KB whose posted envelope was the 596462 that 2026-09-21 expected to be null. So the precondition is a reading rather than an ordering: the paint hands back the page's own clock, `Page.screencastFrame` carries the browser's capture time on that same clock, and only frames stamped at or after the paint are admitted. A frame with no capture time is not admissible either, since it cannot be told from a stale one. When none is admissible the error prints every frame with how long after the paint it was captured, instead of understating the budget in silence. The healthy reading is unchanged -- 0.55296 max, 0.54399 min over three idle runs and three under two concurrent config/scripts suites, against 0.55296 / 0.54399 before -- and so is the cost: 18.4 s against 18.1 s. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
48bdbb8e24 |
refactor(mobile): the host-scoping rewrite moves out of the terminal's HTML (#22172)
`scopeDocumentStyleToHost` and `scopeStyleToHost` are one rewrite of a flat stylesheet, and two page mounts read it: the terminal's and the rich Markdown editor's. The module lived under `terminal/terminal-webview-html/`, so the editor reached across the terminal's directory for it. It moves to `src/style-scoping/`, named for what it does rather than for its first caller, and both mounts import it from there. No re-export shim: the old path is gone. Its test does not follow it whole. Four of its five cases read the terminal's own sheets (`TERMINAL_DOCUMENT_*`, `XTERM_ENGINE_CSS`) and the first asserts `document-style.ts`'s split identity, which is not about the rewrite at all -- so that file stays in the terminal directory as `document-style.test.ts`, beside the module it is about. What moves is the part that names no caller: which selectors read as the document's own, and the sheet shapes both exports refuse. The page-closure pin names the module by path and is updated in the same commit. The closure is otherwise unmoved: 4,363 modules and 1,021 local before and after, one path swapped for another. The five generated artifacts are byte-identical -- none of them bundles this module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
895f2cf477 |
test(mobile): the web app's script fence is re-derived from a measured sweep (OTA phase C, C7.8) (#22152)
* fix(mobile): re-derive the web app script fence from the measured spread (OTA phase C, C7.8) `4r + 16` was a guess at break-even, and its slack ran from 17 scripts at one route to 4 at thirteen -- loosest where nothing is and tightest where the tree actually sits. Re-measured by building every prefix of the sorted route key list: 15 routes emit 67 scripts, and the marginal cost of a route runs 1 to 9 depending on what it shares, so no line through the route count is both an upper bound and a budget. The sweep is now the fence's only input. The envelope is the measurement plus one margin at the swept tree, growing by the worst route the sweep saw for every route past it, so a new route breaches it only by costing more than any route measured. The margin is four, which is the most the count has been seen to move at a fixed route count with no route added: the head that wrote the old fence read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes where this one reads 34, 44, 57 and 65. Two-sided in the test, which is what stops the next bump: a build more than the margin under the envelope fails there too, so the fence has to be re-measured rather than raised. The route count stays the only term and the mermaid control still tells one artifact from 172 chunks. The shell-fit crossing comes in from 50 routes to 31 with it, because the envelope grants the worst swept route where `4r + 16` granted four. Byte budgets re-read from this build and unchanged: 8,053,438 of 9,437,184 total, 1,612,006 before the first route of the 3 MiB allowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spell the session route's grant counts off the table (OTA phase C, C7.8) #22072 deleted `native.wakelock.set` from the session route and left both prose counts behind: "Fourteen grants" for a list of thirteen, and "the four audio verbs" for the three that remain. The all-or-none reason went stale with them -- it argued from a screen free to lock, which is the verb that was removed, and the device side has owned that lock since. It now argues from the microphone a route granted two of the three cannot close. The census beside the route-declaration test is what stops the next one. It reads which number words appear before "grants", "audio verbs", "media verbs" and "or none" anywhere in the file and compares them with the list itself, so a second spelling left in place fails rather than passing on the first correct hit, and a comment rewrapped at a different column still matches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the four sibling comments spell their grant counts off the tables too (round 1) #22072 removed `native.wakelock.set` from the session route and left the count standing in four more files than the route table: "fourteen grants" in the call-site census and in the hop census, "the four audio grants" in the census and its test, and "the four audio verbs" on the bridge schemas. Each is now counted off the table it describes rather than copied beside it. The six-versus-eight split in the call-site census could not be re-derived -- its own "those eight" never summed to the six rows plus the audio grants, so it was wrong before #22072 too. It is replaced by what the tables say today: six rows pin eight of the session route's thirteen grants, and the other five have censuses of their own. Both numbers come from `PAGE_GRANT_CALL_SITES` and the route list, and the existing case that names six rows covering eight grants is what holds them. The census the route table got is now `spelled-count-census.mjs`, driven from three files instead of one. A phrase restated in a file collapses to one claim, so a header and a test name may spell the same count; a phrase with no number before it reads as the empty list, which no table count matches. The bridge test is the one site with no count left to pin: it names `BRIDGE_NATIVE_VERB_NAMES` instead, and a new assertion holds its own `AUDIO_VERBS` equal to that table's audio rows, so the cases below cannot pin one set while reading as coverage of another. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session sentence off the session intersection (round 2) `'of the session'` was counted off every grant in `PAGE_GRANT_CALL_SITES`, which is not what the sentence it pins is about. A row pinning a grant no route declares -- the shell can serve a verb before a screen asks for it -- would have made the census demand the comment overstate what the session route has. Proved before fixing: adding `native.share.send` to the navigate row moved `pinnedHere` from eight to nine while the session intersection stayed at eight, and the census failed asking for "nine of the session route's thirteen grants" against a sentence that was right to say eight. With the intersection it passes under the same probe, and the failure moves to `'grants this file pins'`, which is about this file's rows and does correctly demand nine. The other three rows are left as they were, for the same reason read the other way: `'grants this file pins'` and `'did not'` are about the rows here, so they keep the full list, and `'have censuses of their own'` and `'are not repeated here'` already count the session grants no row pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the two counts in this file's it titles too (round 3) The census pinned the JSDoc counts and stopped there, so "names six rows covering eight grants" and "reaches every one of the eight through the session route" were free to go stale. Shown rather than argued: with a ninth grant on a row and the header corrected to nine the way the old census forced, the suite went green with both titles still saying eight. `'grants this file pins'` becomes `'grants'`, which reads the header and that title as one claim -- they are the same number, and a row per site would have let them disagree while both passed. The session title takes the intersection, for the reason round 2 gave. Every other spelled number in the file is not a count of a table: "the two tables" is how many sources the census reads, and "the one it reads", "a new one cannot be missed", "any one grant went missing" and "every one of" are quantifiers with no table behind them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the session-route title off the rows it asserts over (round 4) `'through the session route'` took the intersection, but the title it pins heads an assertion that compares `grantsNeeded` with every row's grants. The title names the session route and is not a claim about it: it says the rows here are all reached through that route, so it moves when the rows move. In the divergence the JSDoc already described, the two parted. With a ninth grant on a row the assertion compares nine while the census held the title at eight and passed, leaving a title reading below the assertion under it. Exactly one row takes the intersection now, the `.mjs` sentence for how many of the session route's grants these rows cover, and the JSDoc says so rather than describing a rule with two members. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): two wording fixes, and the byte readings re-measured on the merge (round 5) pullfrog on the census JSDoc, both correct. "make the census demand overstates" was a finite verb where the bare infinitive belongs. "Every other row counts the rows here" was contradicted by its own table: the rows reading the route's list and the rows counting grants no row pins take neither count, so the sentence is scoped to the rows that choose between the two and says what the rest read. The two byte readings in the fence doc are re-measured on the merged tree, since they name a head: 8,055,568 of 9,437,184 total and 1,612,253 before the first route. C8.1 added no route, so the sweep and the envelope are untouched and the tree still builds 15 routes into 67 scripts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e47ef8cc28 |
feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1) `routeViewOf` handed the manifest's own route entries to the host as `pageRouteGrants`. The phone reads a manifest route loosely, so an entry arrives carrying whatever field the desktop that wrote it knew about, and `BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the pairs, `createBridgeHost` refuses the route with them, and the page gets no `init` at all rather than losing one field. Fixed before any route carries an optional grant (ruling 37.4), so the manifest field the next commits add costs an installed shell nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: drop the closure and bundle probe scripts from the tree Scratch measurements for C8.1 (which route closures reach the HTML preview, and what the preview render rig costs to bundle with a client provider). They belong outside the repository and were swept in by the previous commit's `git add -A`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1) Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows `optionalGrants` under the required lane's own grammar, with the 16-name ceiling applied over the union of the two lists rather than to each. Serving a route still reads `grants` alone, so a capability a screen cannot work without stays required and takes the route native; a session's granted list is `[...grants, ...optionalGrants]` narrowed to what this shell implements, from one helper that both `grantsForRoute` and the `pageRouteGrants` publish read. The ruling's compatibility rationale is corrected in place. `z.looseObject` passes unknown members through rather than dropping them (measured, zod 4.4.3), so a shell older than the field still receives the key; what it lacks is a policy that reads one. What makes the lane safe against such a shell is therefore the previous commit's publish fix, not the reader. BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1) `externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary` and `haptics`, declared in `cancelled-navigation-target.ts` because that is the module holding the rule which acts on it. A third token that is neither a verb nor a notify: the page posts nothing to make a cancelled top-frame navigation happen, so this list is the only thing that can tell a page whether a tap inside the sealed HTML-preview frame escapes at all. A constant and not a platform read (ruling 37.1): both engines dispatch the event, `ios/MobileWebShellView.swift:481` and Android's `MobileWebShellView.kt:382`, so an app build carries the behaviour on both or on neither. The policy census grows the half that was only pinned by the verb table: the implemented set is that table plus exactly three non-verb tokens, each read off the module that declares it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1) `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration grows reaches a phone only once the map names it: until now `optionalGrants` would have been dropped in silence and every route would have declared nothing optional. Omitted when the route declares none, because absent and empty are the same answer to a shell. The declaration suite grows the rule rather than a row: the map carries the lane through and writes no key without one, and the lane is held to the manifest's own grammar and to the ceiling over the union of the two lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1) The session route declares `externalNavigation` on the optional lane, and the preview asks for it before it renders an artifact's links as links. Ruling 37.2's three readings are what "hide" means here, and removing `href` is what delivers all three at once: `a:any-link` stops matching, so the UA stylesheet stops underlining, the element leaves the tab order, and there is no dead anchor a tap does nothing on. The text the author wrote stays where it was, the artifact paints, and the Preview/Source toggle is untouched. Done with the browser's own parser rather than over the source text: an `href` inside a comment or a `<template>` is text to a browser, and a pass that rewrote either would be editing the artifact instead of its links. The frame also loses `allow-top-navigation-by-user-activation` on that path, so a link the pass somehow missed is refused by the browsing context as well. One route, measured rather than assumed: the design said two, and the file preview route's closure does not reach the HTML preview at all - it renders `MobileFilePreviewScreen`. The new closure census derives that list from the hook's callers. The render rig grows the case on both engines and the readings it needs, and `mobile-web-app-preview-frame-readings.mjs` is split out of it at the readings/arms boundary, because the two were over the 600-line cap together. Two engine findings are recorded in the rig: an `<a>` with no `href` still answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit computes `cursor: auto` for a real link, so that reading is pinned where it discriminates and its blindness pinned where it does not. The hop-coverage census now reads the effective set, because that is what the running rule compares. Inert today: the session route is the only declarer and an opener into every other route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1) The mobile suite runs in a `node` environment whose resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on the native sibling, which answers yes unconditionally. That is why the existing component suite still measured the granted frame without knowing a grant exists, and it means the hidden path had no coverage in the sharded `test` job, where the render rig is skipped for want of the bundler's dependencies. So the wiring gets its own file with the module replaced: that the component asks, and that both the frame's sandbox and the document it is handed follow the one answer. happy-dom rather than the suite default, because the inerting pass parses with the browser's own `DOMParser`. `String(node.type)` rather than a literal comparison: `node.type` is `ElementType`, which overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'` is a no-overlap error under `tsconfig.test.json` and the tests-typecheck ratchet reds on it. Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session page closure at 4,362 for C8.1's three modules Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five postinstall generators run first, and the two `local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules, 1,017 -> 1,020 local. All three are local source modules and none is vendored: the page's read of `init.grants.native`, the pass that turns an artifact's links back into text without the grant, and the module declaring the token beside the rule that acts on it - reached both by that hook and by `page-route-policy.ts`. The `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is replaced rather than joined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): allowlist the preview's grant sibling among the .web.* overrides `mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*` files on disk, so a new web sibling reds it until the file says why the page needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming `src/components/use-html-preview-link-grant.web.ts`. The preview's own entry is corrected with it: its reason said `allow-top-navigation-by-user-activation` is granted, and that token is now conditional on the shell answering that it can open such a navigation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the hidden-link render case waits on the frame's own reading (round 1) CI's chromium arm timed out at the full 240 s on this case alone while the WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the third arm: it tapped the granted link and waited through `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the case's own timeout. Under CI load the click missed its 2 s actionability window, no navigation was ever recorded, and the arm sat in that wait until vitest gave up - `recorded []`, with the frame attached only at 38.9 s. Three arms sharing one budget is what made this the case to find it. The arm is dropped rather than its wait lengthened or retried. Every verdict left is a reading the frame itself publishes: the anchors its document holds, the style the engine computed for one, whether focus lands on it, and now whether the tap this arm made landed at all - `actError` is asserted null, so a click that never reached its target is no longer the same three zeros as a tap that did nothing. Nothing is lost. The tap's outcome on a granted shell is the next case, on these same counters from this same rig and with a budget of its own, which is the presence precondition this file already uses elsewhere for the same reason. The WebKit sibling's discriminating reads are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2) Round 2's ruling: the hidden-link path may change nothing about the artifact's rendering except that links are not links. A parse and a reserialise is not free of that by default, and all four findings reproduced on Chromium 147 and WebKit 26.4. A same-document fragment link is kept. It starts no navigation at all, so it goes on working inside the sealed frame whatever the shell can do, and taking it away would be degradation over a capability it never needed - an artifact's own table of contents is the case. Its `target` still goes, because a fragment aimed at another frame is a navigation rather than a scroll, and `href=""` is not a fragment: it resolves to the frame's own URL. Links inside `template.content` are reached, recursively. `<template shadowrootmode>` is a declarative shadow root the frame's parser attaches and renders, and `querySelectorAll` does not walk into template content, so those links arrived live inside a sandbox that refuses their navigation - the dead anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root on either engine or in happy-dom, so the pass can reach them. The leading newline of a `pre`, `listing` or `textarea` is written back. A parser drops one after the start tag and the serialiser is specified to put it back; measured, neither engine's does, so a round trip lost a blank line from every such block. The doctype is carried whole, and the reason is corrected from the one the finding gave. It cannot move this frame between layout modes: a `srcdoc` document takes its mode from its embedder, and measured, a quirks doctype, the bare name and no doctype at all all read `CSS1Compat` inside the frame. What rewriting it does is change the document the author wrote for no reason, with `document.doctype` observable beside a Source tab showing the original. The render case pins `compatMode` as the blind reading it is and reads the frame's own doctype identifiers as the one that discriminates. Option B was not available: the frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs inside it and there is no injection to carry the work. Also drops a vacuous half of the affordance test. `renderSource()` is called with no argument, so the markup a Source view shows is the caller's own closure and asserting it equals the fixture passed whatever the component did. What the component decides is whether the rewritten frame stays mounted underneath, and that is what is read now. `mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the boundary the readings module already names - the rig holds what each case claims, the driver how an arm is driven, the readings what it reports - since the three were over the 600-line cap together. No max-lines disable or bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3) pullfrog is right, reproduced on both engines before believing it. Round 2 kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In this frame they are not: the document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so `#section` resolves against the shell's own URL and the destination differs from the document's by more than a fragment - which makes activating it a frame navigation, and the shipped `frame-src 'none'` refuses it. Measured under the shipped policy, one tap, with something to scroll: Chromium 147 scrollY 0, frame becomes chrome-error://chromewebdata/, artifact gone, embedder reports frame-src <origin>/preview WebKit 26.4 scrollY 0, frame stays about:srcdoc and intact, same report So the destruction is Chromium-only but the absence of a scroll is not: there was no working affordance to carve out for, and the carve-out left a live link that destroys the preview - worse than the inert text it was meant to avoid. Both sandbox values behave the same, so this is the base URL and the policy rather than the sandbox. The same tap does the same thing on the granted path, where this pass does not run, so an artifact's internal links have never worked in the preview. That is not this change's to fix; it is recorded in `followup-html-preview-fragment-links.md`, and the render case reads the granted arm's violation as its presence precondition so the behaviour is pinned rather than merely known. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
35897da0aa |
fix(mobile): serialize a list the engine nested inside a paragraph (#22145)
`insertUnorderedList` puts the `<ul>` inside the `<p>` it was given rather than replacing it — measured on WebKit 26.4 and Chromium 147 both — and `blockMarkdown` read such a paragraph inline. A bullet list the user typed came back as the paragraph's own text with no marker, so it did not survive a markdown round trip, on the page and in the native WebView alike. The serializer now reads structure wherever the list sits: text before it is a paragraph, the list is a list, text after is a paragraph. The DOM is left as the engine made it and no branch asks which engine is running. The parse side needs no mirror — it already renders `- x` as a top-level `<ul>`, which is the shape the fixed serializer reports, and the flat control case pins that. The unit fixture is built through the paragraph's own `innerHTML`: the HTML parser closes a `<p>` before a `<ul>`, so a markup string on the editor gives two siblings and would measure the flat shape. Each case asserts the nesting it got before it reads anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9243073b73 |
test(mobile): repin the session page closure at 4,360 after #21705 reached it (#22135)
#21705 added agent-session-option-catalog-antigravity.ts to the option catalog the session page reaches. It merged beside C2 (#22099), whose pin of 4,359 was measured before it, so main reads one short. Measured at |
||
|
|
841d06a969 |
feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root The markup gives the editable surface an id, and inside the WebView that is unambiguous because the document is the page. On the page it is not: a stack transition keeps the outgoing session screen mounted while the incoming one starts, so two hosts carry `#editor` at once and a page-wide `getElementById` hands both documents the first one. The seventh seam is the root, exactly as it is the terminal's ninth (ruling 24): the WebView names none of them and gets the whole page, the page names the element its mount planted the markup in. Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts` against the page-wide read: 4 failed, 1 passed — content written into the second host landed in the first, both documents serialized the first surface, an edit in the second reported through the first document's host, and stopping the first took the listeners off the surface the second was still using. The one that passed is the control: a document with no root still reads the whole page, which is what the WebView gets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold the editor's document rules under its host element The editor's sheet says `:root`, `*`, `html` and `body` because inside the WebView it owns the page. Appended to the head of a React Native Web application all four restyle every screen the shell can show, so the page mount may inject only what it owns — ruling 19's rule for `window.onerror`, applied to CSS. The terminal's half of the scoper drops those rules and repaints through a seam, because the colour `html, body` was setting belongs to the application. The editor has no such seam and needs none: its host element *is* that editor's page, so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the variables every other rule reads, the surface colour, the font, the box model — and everything else hangs under it. A selector that merely starts at the document (`body p`) throws rather than being rewritten into something it did not say. Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`: 6 failed, 0 passed, all on the absent export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the fifteen toolbar commands are one row both surfaces render The row of controls is not the WebView's: a press becomes an injected `runCommand` there and a call on the page, and neither difference belongs in the toolbar. Extracted so the page's editor does not declare fifteen rows of its own that would drift from the phone's. `MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have needed: the row names every command in the contract, exactly once. Verified red by dropping `codeBlock` from the row — "names every command in the contract, once" failed on the 14-member list before the case went back. The native component's own test and the web fallbacks file stay green unchanged, which is what says the extraction moved nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the toolbar test inside the tests-typecheck ratchet `check-tests-typecheck-ratchet.mjs` reported the new file as newly failing `tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any `createElement` overload, and comparing a node's `ElementType` against the string `'Pressable'` is a no-overlap comparison. Host strings for the mock and `String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800 files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich Markdown editor mounts on the page `react-native-webview` renders nothing in a browser, so C7.6 gave the page a plain Markdown field and recorded the toolbar and the rendered view as a degradation. Ruling 26 makes that debt rather than done: the page mounts the document itself. `rich-markdown-web-document-mount.ts` is the editor's half of what `terminal-web-document-mount.ts` does for the terminal — the sheet held under the host's class, the markup planted in the host, one factory call, and a dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the component over it, with the same fifteen-command toolbar and the same controller the phone uses, so `MarkdownReader` cannot tell which sibling it has. Three seams are the page's rather than the window's. Messages reach `handleMessage` directly and never `window.ReactNativeWebView`, which on the page is the shell's bridge. The URL for Link and Image comes from `TextInputModal`: `window.prompt` was measured to return null in both shells, so those two commands silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange` is never called — the screen's `keyboard-occlusion.web.ts` measures the same viewport with the same formula, and a report here would lift its bar twice. Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the absent module, and its listener case is the one that holds ruling 21 — a second mount reports its own edits and the first mount's detached surface reports nothing, with an event dispatched on it to say so. The four new cases in `mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput` the page is meant to have lost. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it `#editor` computed to 14 px, measured in both engines. iOS zooms the page on focus of any editable under 16 px and does not zoom back, and `keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale other than 1 — the exact failure the floor exists for, on the page's only full-screen writing surface. The size now comes from the text-input seam, which is also where the two hosts part: the phone keeps the app's body size because a WebView has no page to zoom, the page gets the raise, and one binding moves both if the floor ever does. The `TextInput` census could not have caught it. `modulesDeclaringTextInput` matches JSX tags and `style` props, and this is a `contenteditable` in a markup string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs` starts from the markup instead: it finds every editable host a closure declares, follows its id to the rule beside it, and reads the size the same way — a literal at or above the floor, or the seam's own export imported from the seam's module. An editable with no id, or one no sibling sheet styles, is reported unresolved rather than passed. Red first. The rule's own file reported `src/components/rich-markdown/document-style.ts:36` as the offender before the fix (4 failed, 3 passed on the first run, the other three being the brace scanner and the line-start anchor the fixtures found). The closure case in `mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the one editable in the session route's closure and its offender list is empty: 1 passed, 4 skipped under `-t editables`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the caret survives the host's URL dialog, so Link and Image insert Measured in the render check, on both engines: the Link and Image commands opened the modal, took the URL, and inserted nothing. The dialog is what takes the caret — the modal focuses its own field — and `execCommand` on a document that does not hold the selection does nothing at all. So the page had swapped one silent failure for another: `window.prompt` returning null on the phone, and a command with no selection on the page. Two halves. The document remembers its caret before it waits and puts it back after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd` needs a flag, because the wait itself is the blur); if the host replaced the content while the dialog was open, the remembered range is gone from the document and the caret goes to the end instead. And the component answers the promise from the drawer's `onAfterClose` rather than from the submit, because WebKit would not take the focus back while the field still held it — with the answer released on submit, chromium inserted and WebKit did not. `TextInputModal` forwards `onAfterClose` for that, which is the one thing it did not already pass through to `BottomDrawer`. Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`: 2 failed, 7 passed — the caret was left in the dialog's field, and a replaced document did not fall back to the end. The render check's Link/Image case went from failing on both engines to inserting on both, with the inserted image's `naturalWidth` above zero under the shipped policy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's rich Markdown editor, in both engines under the shipped header `config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component, mounted by the real React, driven through its toolbar in chromium and webkit under the policy read out of the shell's own Kotlin source. Sixteen cases, eight per engine. What it measures rather than asserts: all fifteen commands change the document, each with the precondition that what it produces was not there first; the surface computes to the 16 px floor and the document's own `--editor-surface` variable is set on the host and nowhere on the root element; `ready` and `change` cross the seam while `window.ReactNativeWebView` — defined by the rig so its absence is a reading — is never touched; Link and Image are answered by the modal, and the inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap and one per inline code; a link tap reaches the host instead of navigating; a remount leaves the listener snapshot and the scheduler exactly where one whole cycle left them (rulings 20 and 21); and two editors on one page hold their own content and report their own edits. Four harness facts the first runs found, each now in a comment: the entry needs four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or `react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the route's navigator supplies and a bare mount does not; and the document's markup, not its text, as the oracle for a content reset — `### body text here` and `body text here` read the same, so a text wait passed on the document it was replacing. One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>` inside the `<p>` it was given and the serializer walks back out with the same text, so a bullet list does not survive a round trip there. The phone's WebView is the same engine, so this is not something the page introduces; the case names the command's own element and the reset is numbered per command to work around it. Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session route's closure for the editor on the page Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `9267423f22`, all five postinstall generators run first, the before side a scratch worktree detached at that sha: modules 4333 -> 4360 (+27) local modules 991 -> 1018 (+27) All 27 are local and none is vendored, which is the point: the editor is the app's own code, not a library. The document's 24 modules under `src/components/rich-markdown/` were reachable from nothing on the page while it rendered a plain field, and the other three are the mount, the shared toolbar, and the controller with its keyboard-inset module. Nothing leaves, because the web sibling replaces its own native file and that file was never in this closure. Named by diffing the two `local` lists, not inferred from the total. `document-style-scoping.ts` is on both sides: the terminal's mount already brings it, so the editor's second export costs no module. The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes (+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script count does not move (67 against the 76 the chunk fence allows for 15 routes) and neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is code the route already reached for, not a new chunk boundary. The grant census needs nothing: `openExternalLink` is the editor's only seam with a grant behind it, and the session route already declares `externalLink` for six other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged. Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an oxlint directive the changed-code gate reads as unused `check-changed-code-quality.mjs` failed with one finding: the mount effect's `react-hooks/exhaustive-deps` disable reports no problem under that config, so the directive itself is the finding. The reason it carried is worth keeping and now reads as a plain comment — the effect mounts once, with `promptForUrl` taken from the closure, because re-running it would throw away a live document and the caret in it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census counts every editable tag, not every id CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the code: the pattern started from `id="…"`, so it matched only hosts that carry one. The no-id guard fired for a file with *no* named host at all, which means a file holding a named host beside an anonymous one reported the named one as clean and said nothing about the other. An editable is its tag; the id is read out of the tag afterwards. Also `:145`, also right: the sibling search was `startsWith(directory + '/')`, which reaches the subtree, and the walk stops at the first file whose sheet opens the host's selector. The closure's order is the bundler's rather than alphabetical, so a sheet one directory down could answer for the sibling the host actually gets. Now the immediate directory only. Red first, both cases in the census's own file. The mixed fixture reported one host where two were planted (1 failed, 7 passed); the nested fixture, with the nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px sibling and reported no offender (1 failed, 8 passed). 9 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): an editable with no declared size is unresolved, not a pass CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no `font-size`, so the offender check accepted the host without being able to say what size it gets. The inherited value comes from a rule this walk does not read — the host element's own, or the page's root — and it can be 14 px. So "no declaration" becomes "cannot say" and lands in `unresolvedEditableHostStyles`, which the session closure census holds at empty. Not an offender: an offender is a size this walk read and found under the floor. The `TextInput` half of the seam still lets an absent `fontSize` through as inheritance. That is main's policy and it is about a prop rather than a cascade, so it is not touched here; the divergence is stated in the reader's own comment. Red first: the inheritance fixture reported no unresolved host where the size is unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected — the editor declares its size on the seam — and the closure census still reads an empty unresolved list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census reads the font-size the cascade uses CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk read the first `font-size` in a rule, and CSS takes the last of equal importance. `font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface the browser renders at 14 px. `!important` outranks every declaration that is not, whatever the order. The flag is also stripped from the value, which the finding did not name but the fixture caught: without that, a compliant size carrying `!important` was reported as an offender, because it matched neither the literal nor the substitution shape. The declarations are split on the separator rather than matched with a value pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of its own interpolation and reported the real editor as an offender — caught on the first run of the fix, and the reason the split is the shape here. Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no offender, and the important-declaration pair reported the wrong one of the two. 11 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the render check reads the floor from the seam instead of retyping it pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment said the floor was read from the seam and the constant was the literal `16`, which is the shape the seam exists to prevent. It now comes from `textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which throws rather than defaulting when the seam is gone. The assertion becomes "at or above the floor" rather than equal to it. The seam is `Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises what the page computes; equality against the floor would have been the same stale literal one module further away. Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam keeps the case green on both engines, because the stylesheet reads the same module and the page computed 18 — the two moving together is the point. Replacing the stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both engines, `expected 14 to be greater than or equal to 16`, which is what says the assertion carries weight. Both files were restored. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the editable-host census reads every rule that sizes the host (round 3) `ruleFor` returned the first exact `#id` rule and the walk stopped there, so a later exact rule of equal specificity, or a higher-specificity subject rule that still targets the host, could lower the rendered size unseen. Every exact rule in the sheet is now collected in source order and read as one cascade, and any other rule whose subject compound targets the host and declares `font-size` makes the host unresolved rather than compliant. No specificity arithmetic, and the sibling walk is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
197550c952 |
test(mobile): repin the session page closure at 4,333 after #18790 reached it (#22119)
#18790 added the freebuff agent icon to mobile-agent-icon-assets.ts, which the session page reaches. It merged between #22114's closure measurement (4,332) and its merge, so main pins one module short. Measured on main's tip |
||
|
|
0b1567a7b1 |
fix(mobile): catch the diff-comments loader rejection at the effect (OTA phase C follow-up) (#22111)
* fix(mobile): catch the diff-comments loader rejection at the effect `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` with no catch, so a *rejected* `worktree.show` raised an unhandled rejection on every session mount: a document-level error, not a page fault, and a red herring in crash reports and device proofs. The catch goes at the effect rather than inside the loader, whose promise the recording adapter awaits. `config/scripts/mobile-web-app-session-render.test.mjs` pinned the page's error list to exactly that one rejection; it is now the empty list, which is what makes the browser proof notice the fix. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record goldens without the diff-comments rejection The corpus certified the unhandled rejection the commit before removes, so the golden had to move with it. Scoped re-record: `baseline` bumped by editing that one line, then `--record`. Every diff line classified: - `baseline`, 788 lines (787 goldens + `pilot-scenarios.json`), and nothing else in 786 of them. - `matrix-session.diff-notes-worktree.show-1.json`, the only golden with a substantive change: three `unhandled-rejection` effects leave the pool (`da8252771fbd` incompatible_reply, `d638e32b9559` transport failure, `cf4fa55e3a8d` empty) and the four checkpoints that carried them now read `"effects": []`. No renumbering; no other effect key moved. - `HEAD_EFFECT_SHA256` to the measured `cc25f370…ebe522`. The 24-effect count is unchanged. `recorderSha256`, `lockfileSha256` and `adapterSha256` all hold. `baseline` is a branch commit, so a repin to main follows the squash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
226f4a0775 |
fix(mobile): two more table parsers hold a pipe in a cell (OTA phase C follow-up) (#22114)
* test(mobile): pin escaped pipes in mobile markdown table cells The mobile preview parser splits a table row on every pipe, so a cell that escaped one becomes two cells and keeps the backslash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read table rows through the shared row splitter The editor's markdown-table-rows already splits on unescaped pipes only and unescapes the cell; it has no imports of its own, so owning the rule once costs the preview parser nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin escaped-pipe rows in PR comment tables Its splitter strips the trailing pipe before walking escapes and reads `\\|` as an escaped pipe, so a row ending in `\|` loses the pipe and a cell holding a backslash swallows the separator after it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): split PR comment table rows on unescaped pipes only Its own delimiter grammar stays local: a single dash still opens a table here, which the editor's three-dash separator would reject. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): repin the session route closure at 4,332 modules markdown-table-rows.ts joins through the PR comment renderer. Measured on this head: 4,332 modules / 990 local, and it is the only file under rich-markdown/ in the closure, so nothing came with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0677271709 |
fix(orchestration): reap leaked worker terminals via process-incarnation fallback — stops an unbounded PTY/process leak on Remote Server (OOM / cgroup PID exhaustion) (#18790)
* fix(orchestration): remint live handle from process incarnation on worker release When a durable terminal handle goes stale (rendererGraphEpoch fence), inspectWorkerTerminal re-mints a live handle via resolveTerminalHandleByProcessIncarnation + matchesProcessIncarnation so release/stop/read act on the still-running PTY instead of reporting missing and leaking the agent process tree. - keep main shared host-scope re-exports; add matchesProcessIncarnation - wire observation.terminalHandle through control/stop/release - rebuild release-completion on main structured paths - on missing/unattached + provably exited: settleDead fence first, then same-incarnation settleWorker fall back (archive may block settleDead mid-request); settle before recovery defer * fix(orchestration): derive SSH host scope from the reminted handle; reuse fresh-request recovery guidance for structured workers Addresses two open CodeRabbit review comments on PR #18790. inspectWorkerTerminal read the dispatch authority with the stale durable terminalHandle, so after a remint the lookup resolved nowhere and currentHostScope was always undefined — an SSH worker with no liveness verdict and no persisted host_scope got classified from terminal.connected instead of unverifiable. It now reads the same effectiveHandle every other observation in the function uses. stopStructuredWorkerForRelease told the caller to repeat the release with the same --retry-request, which only replays the stale release_unknown receipt and made a structured-worker close failure permanently unretryable. It now sources releaseUnknownRecovery from worker-release-completion so the fresh-request-ID guidance lives in one place. Pre-commit lint-staged (oxlint + oxfmt) run manually: clean. * test(orchestration): exercise incarnation recovery through runtime paths * test(orchestration): pin the incarnation read scenario to the reminted terminal The read scenario only asserted that the call resolved, so it documented nothing about which handle the read reached. Assert that the handle readTerminal received resolves to the registered pane and incarnation, so the scenario proves the read went through the reminted terminal instead of passing on the incarnation fence's throw. * refactor(orchestration): drop redundant incarnation prefix check; require liveTerminalHandle * feat: add freebuff as a first-class TUI agent (#42) <!-- orca-pr-loc --> <!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. --> | | Files | Added | Deleted | Net | | :--- | ---: | ---: | ---: | ---: | | Test | 0 | 0 | 0 | 0 | | Prod | 28 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | 0 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | <!-- /orca-pr-loc --> ## ELI5 Add Freebuff (`freebuff`) as a recognized first-class TUI coding agent in Orca alongside Codebuff and other supported agents. ## What Changed - Registered `freebuff` across shared TUI agent definitions, configuration catalogs, display names, and telemetry schemas. - Added agent icons, favicons, status mappings, and mobile asset references for Freebuff. - Added localization strings across supported language packs (`en`, `es`, `fr`, `ja`, `ko`, `zh`) and updated locale translation policy. - Documented Freebuff CLI in README agent table (`npm i -g freebuff`). ## Why Freebuff is a CLI coding agent twin of Codebuff (`npm i -g freebuff`). Adding it to the catalog enables users to launch worktrees, run automated sessions, and pick Freebuff directly within Orca. ## Linked Issue N/A ## Visual Proof `N/A` - Catalog registration and metadata definition for CLI agent launch; UI rendering uses existing TUI agent picker and status components. ## Testing - Verified TypeScript contracts, schemas, and catalog configurations. - Tested CLI detection / agent picker integration locally on Linux (`worktree create --agent freebuff`). ## AI Disclosure Assisted by AI coding tooling. ## Checklist - [x] This PR is small and focused - [x] I explained what changed and why (including ELI5) - [x] Before/after screenshots or videos attached for UI changes, or `N/A` with reason - [x] Self-reviewed for correctness, security, and performance - [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) --------- Co-authored-by: Lesley Murfin <lesley@revivebusiness.ca> * test(orchestration): erase method overloads in worker reap fixtures * test: document worker fixture type boundaries * test: simplify worker fixture typing --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: svc-orca[bot] <313947298+svc-orca[bot]@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
88f2f01061 |
fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY (#19430)
* fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY Root cause: daemon-launched-child.ts forks the detached terminal daemon with detached: true, which escapes the POSIX process group (setsid) but never the systemd cgroup. Every PTY the daemon owns is itself an undetached direct child of the daemon (native-pty-spawn.ts). Under a combined systemd unit (Type=simple, KillMode=mixed, per docs/reference/headless-linux-server.md), a systemctl restart/stop SIGKILLs every process still in the cgroup at the stop timeout -- the daemon and every live terminal -- even though the codebase already has a fully-built adoption/reattachment path for a surviving daemon (orcad-entry.ts's refreshRestoredOrchestrationAuthority + reconcileLegacyWorkerTerminals, gated on daemonOwnsFreshPersistentPtys()). That path never fires today because the daemon never survives long enough. Fix: when systemd is actually supervising the process and the OS user has a reachable systemd --user manager (isDurableDaemonScopeSupported(), Linux only), launch the daemon via systemd-run --user --scope so it lands in a cgroup that is a sibling of the service unit's cgroup, not a descendant of it. A systemctl restart of the combined unit then never reaches it. Any failure of the scoped launch (no reachable bus, D-Bus policy rejection, etc.) falls back transparently to the existing plain fork() launch, so every platform/environment without this capability is unaffected. The daemon self-detects its own resulting cgroup scope via /proc/self/cgroup (detectOwnCgroupScopeUnit()) rather than trusting the launcher's intent, and publishes it as cgroupUnit in its pid record and orcad's health/readiness payload (health.terminalDaemon.cgroupUnit), so a running deployment can be observed to confirm the fix actually engaged. No new session registry is added: the existing daemon pid-record + adoption protocol (publishDaemonPidFile, daemon-pid-record-quarantine.ts's dead-record reclaim, refreshRestoredOrchestrationAuthority) already implements durable, crash-safe reattachment for a surviving daemon -- it was simply never exercised against a full unit restart before now. Proven via a systemd-in-Docker recovery test: a live PTY session's shell process, its daemon, and the daemon's cgroup scope were all confirmed unchanged across a real systemctl restart of a Type=simple/KillMode=mixed unit, while the main process pid changed (confirming the unit actually restarted) and the new process's health payload recognized the surviving daemon as adopted and live. A fresh write into the same PTY post-restart reached the same running shell. Ordinary terminal create/work/release and the #18789/#18790 worker-release reap-fix regression tests are unaffected. Fixes stablyai/orca#19408 * fix(daemon): probe the real per-UID XDG_RUNTIME_DIR before trusting the process's own env isDurableDaemonScopeSupported()/buildDurableDaemonScopeCommand() trusted the current process's own XDG_RUNTIME_DIR env var first, falling back to /run/user/<uid> only when that var was unset entirely. On mtl-02, orca-serve@factory.service's RuntimeDirectory= hardening directive makes systemd export XDG_RUNTIME_DIR=/run/orca_serve/factory into the unit's process -- a private scratch dir that shares the env var's name but has nothing to do with the user session bus. /proc/<pid>/environ on that host confirmed exactly that path plus DBUS_SESSION_BUS_ADDRESS=disabled:, while the real bus was reachable the whole time at /run/user/985 (confirmed via systemctl --user is-system-running with that dir exported by hand). The probe treated the hardened override as authoritative, found no bus socket there, and reported unsupported on every launch -- so the cgroup-escape fix from #19408/#19430 never actually engaged on real hardware, even though tonight's factory deployment picked it up. Fix: resolveUserRuntimeDir() now always tries the conventional /run/user/<uid> path first (computed independently via getuid(), never trusted from env), checking for a genuinely connectable bus socket via statSync(...).isSocket() rather than a bare existsSync. It falls back to the process's own XDG_RUNTIME_DIR only when that canonical path has no reachable bus -- covering hosts that legitimately have no /run/user/<uid> at all but do have a working bus wherever their own environment points. buildDurableDaemonScopeCommand() now explicitly sets XDG_RUNTIME_DIR to whichever path this resolution picked, rather than inheriting the spread env's (possibly hardened-wrong) value. Both isDurableDaemonScopeSupported() and buildDurableDaemonScopeCommand() gained an injectable canonicalRuntimeDir parameter (defaulting to the real computed path) so tests can exercise the hardened-override scenario deterministically with a real, connectable AF_UNIX socket fixture instead of the live host's actual runtime directory. Docker's stock jrei/systemd-ubuntu test container never had this hardening directive, so this gap was structurally invisible to the container-based verification in #19430 -- only caught against real mtl-02 hardware. * fix(daemon): report the daemon's own pid over the ready handshake, not systemd-run's The launcher used to infer the daemon's identity pid from the immediate spawned child (`child.pid`). On the durable-scope path that child is `systemd-run --user --scope`, not the daemon, so the launcher was asserting an identity it had no authority over. `DaemonReadyIdentity` now carries a required `pid` populated from `process.pid` inside the daemon itself, and `daemon-launched-child.ts` takes `launchedIdentity.pid` from that self-report. Both sides of the `holdDaemonAdoptionLease` pid comparison therefore originate inside the daemon process, which is the idiom this branch already uses for cgroup membership (`detectOwnCgroupScopeUnit` reads `/proc/self/cgroup` rather than trusting what the launcher intended). Note on the reported consequence: `systemd-run --scope` registers its *own* pid on the transient scope unit and then `execvpe()`s the target command -- same pid, no intermediate process -- so adoption did not in fact fail on systemd >= 206 (verified against systemd 255.4-1ubuntu8.17 and current main, `src/run/run.c` `start_transient_scope()`). The fix stands on its own merits: it removes a silent dependency on that exec-vs-fork implementation detail, which a `systemd-run` shim earlier in PATH or any future systemd change would have broken with no diagnostic. `terminateLaunchedDaemonChild` was audited and deliberately left on `child.pid`: for the same execve-preserves-pid reason that pid is either still systemd-run mid-scope-setup (killing it correctly aborts the launch) or already the daemon, so it targets the right process either way. Regression coverage: `daemon-launched-child-identity.test.ts` pins the identity source, and `daemon-ready-identity.test.ts` gains pid-validation cases. Ready-message fixtures across the `daemon-init-*` suites were updated for the now-mandatory field. Addresses: https://github.com/stablyai/orca/pull/19430#discussion_r3953722704 https://github.com/stablyai/orca/pull/19430#discussion_r3954346518 * test(daemon): assert cgroupUnit in the pid-file parse contract `parseDaemonPidFile` returns `cgroupUnit` on every branch as of the durable-scope commit on this branch, but five exhaustive `toEqual` assertions in daemon-health.test.ts still described the pre-scope shape, so they failed on the branch independently of any later change. Adds the field to those expectations. Deliberately not relaxed to `toMatchObject`: asserting the full parsed shape is what makes these tests catch a field silently dropped from the pid-file contract. * refactor(daemon): resolve the canonical user runtime dir at one point The per-UID path cannot change for a live process, so compute it once into a module const instead of threading the same default call through three signatures, and drop the try/catch around a getuid() that cannot throw once it exists. Trims the module prose to the non-obvious facts and corrects the pid-file record comment: an unscoped daemon writes null; only records no daemon wrote are absent. * test(daemon): clean up the cgroup-scope fixtures and assert a verdict The cgroup fixture tracked only the file it wrote, leaking one temp dir per case. Drains both fixture lists with splice so the pop-may-be-undefined guards go away, and replaces a not-throw/typeof-boolean pair with the verdict it was circling: no resolvable runtime dir means unsupported. * refactor(daemon): share the detached child options across both launch paths cwd, detached and stdio were repeated in the fork and systemd-run branches, which left the two comments explaining them hovering over the env block instead. Names them once so each branch carries only its own delta. * refactor(daemon): validate the ready pid like every other field typeof-first narrows the value, so the two 'as number' casts the isSafeInteger check needed disappear and the pid guard reads like the startedAtMs guard below it. * fix(daemon): don't retry the launch unscoped after losing the endpoint race A scoped attempt that lost the endpoint to another daemon was retried unscoped: a second doomed fork, a misleading 'cgroup-scope launch failed' warning, and the same DaemonEndpointUnavailableError the caller was already going to adopt on. Rethrows it instead, since no launch mode can win a race that is already lost. Also drops a private alias for DaemonChildSpawnOptions and the two 'as number' casts on child.pid in the startup-failure cleanup. * fix(daemon): unlink the pid record by the pid the daemon published The record holds the daemon's self-reported pid, so match on that rather than on the immediate child's, which is the systemd-run wrapper's until it execs. * fix(daemon): route the scope launch through the child-process chokepoint The two files this PR added imported `node:child_process` directly, which `child-process-import-boundary.test.ts` fails on deterministically: the offender count went 155 -> 157 against a pin of exactly 155. Raising the pin or listing the files is what that test explicitly forbids, and the allowlist's own note says a split "moved the import, it did not add one" -- so the fix is to get both new files off the module and put the count back at 155. - `daemon-cgroup-scope.ts`: the `systemd-run --version` probe now uses `runProcessSync` instead of `execFileSync`, so it gets the shared spawn decisions. Kept synchronous deliberately: `launchDaemonChild` attaches the readiness listener in the same tick it is called, and an await before the spawn moves the child past that tick. A non-zero exit is data rather than a throw here, so the verdict now checks `code === 0 && !timedOut`. - `daemon-launched-child-spawn.ts`: the scoped launch uses `spawnProcess`, and the long-standing unscoped launch keeps `fork` semantics through a new `forkProcess`. - `src/shared/child-process/fork-process.ts`: the fork arm of the chokepoint. `spawnProcess` cannot express a Node child with an IPC channel started from a module path under an overridden `execPath`, and the existing launch tests are written against `fork`'s contract, so a spawn rewrite would have changed module resolution, `execPath` and `execArgv` at once. It passes `windowsHide: true` -- the flag every other call site in that directory sets, reachable via an assertion because `ForkOptions` omits it -- which keeps `windows-console-visibility.test.ts` at its pin of 65 too. Both ratchets pass with both pins and both allowlists untouched. Docs: `orcad-operations.md` and `headless-linux-server.md` still described the limitation this PR removes as permanent. Both now describe the durable-scope survival path and its preconditions (systemd as PID 1, a reachable user bus / `loginctl enable-linger`, `systemd-run` on PATH), and scope the old text to the unscoped-fallback case, pointing at `health.terminalDaemon.cgroupUnit` as the way to tell the two apart on a running host. * fix(daemon): seal the cgroup capability probe from the host and correct KillMode=mixed docs The capability probe consulted the host's own /run/systemd/system marker and spawned the real systemd-run binary, so the hermetic unit tests could only pass on a systemd host (and fail closed otherwise, even with faked bus sockets). - Thread systemdBootPath and runVersionProbe as test seams through isDurableDaemonScopeSupported, defaulting to the real boot marker and systemd-run --version probe in production. - Narrow the injected probe to the ProcessResult slice it consumes. - Cover: no-systemd-boot, non-zero probe exit, and probe-timeout cases. - Correct KillMode=mixed semantics in the docs: the cgroup-wide SIGKILL fires the instant the main process exits, not after TimeoutStopSec; document the Docker-container caveat and add KillMode=mixed to the multi-service template. * fix(daemon): satisfy assertion checks in scoped launch * fix(daemon): satisfy anti-slop and console guards * test(serve): update shutdown docs assertions for daemon scope * fix(daemon): migrate adopted legacy scopes * docs: qualify restart safety by daemon scope * docs(daemon): qualify Upgrade restart prose with durable scope caveat Align the Upgrade section in docs/reference/headless-linux-server.md with the earlier preservation section and docs/reference/orcad-operations.md: a service restart terminates live processes only when running under the unscoped fallback, and stops should be treated as destructive unless health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope. Update the shutdown workflow test assertion in config/scripts/headless-serve-shutdown-workflow.test.mjs to match. * fix(daemon): harden legacy scope migration --------- Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
e1c8df41e5 |
fix(mobile): declare externalLink on the two page routes that reach the protocol wall (OTA phase C follow-up) (#22113)
* test(mobile): hold every page route to the externalLink call site it reaches The grant call-site census carried an exact allowance for the two routes that reach the shared protocol wall's `openExternalLink` without declaring `externalLink`. Removing it makes the census enforce the declaration instead of recording the gap; it now names both routes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): declare externalLink on the worktree list and agent history Both routes render the shared layout's `HostProtocolGate`, whose `ProtocolBlockScreen` opens its Update Orca link through `openExternalLink`, and neither declared the grant: the tap posted a notify the shell refuses, with nothing on screen. The census measures one call site in each closure, `src/components/ProtocolBlockScreen.tsx`. Repinned by measurement, with the manifest change named: the route-list pin, and the handed-off hop census, which goes 23 rows to 19. The four rows that leave are these two routes into the explorer and its preview — all four now declare the same four grants, so a tapped file stays in the document instead of costing a native frame and a second bridge session. A new case asserts that coverage, so the four absences are load-bearing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
86b93e02a7 |
feat(mobile): the microphone owns the wake lock, and the stop reply carries the tail (OTA phase C, ruling 36) (#22072)
* feat(mobile): give the microphone its own screen lock (OTA phase C, ruling 36) An open microphone holds the screen; a closed one gives it back. The lock lives in the device-side capture on both hosts — the shell's `native.audio.start|stop` handler and the native seam — so the page never decides anything about the screen. One tag per capture, minted by the module that owns the mic. Both captures give it back on every close path: a stop, a page session ending with the capture open, a device that would not begin, and an engine that throws after the capture is open, which now ends the capture rather than leaving it live. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36) `native.audio.stop` drains what the ring still holds into its own reply, so the page's `end()` is one verb: stop, hand the bytes on, done. The drain, await and read-once-more ordering goes with it, and so do `ending`, `reading` and `released` — three variables that existed only to order a last read against the stop and to stop a refused read re-entering `end`. The tail fields default rather than being required: the page updates over the air and the shell does not, so a page this new can meet a shell that answers `stopped` alone. That dictation loses its tail where a required field would have lost it the stop. The heap case from PR D's bot round cannot recur: `end` issues no read, and a stop reply carries no interruption, so the lane that re-entered is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dictation finish id, which ordered nothing `finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36) The page never names the screen now. `native.wakelock.set` is gone with its schemas, its shell server, its grant rows and its harness entry; so are the page's keep-awake owner, the Android foreground re-acquire, and the `DictationKeepAwakeDevice` the capture contract carried. One module holds the screen — the device calls the microphone's capture makes — and both device-side captures share its one tag, because there is one microphone. Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140), mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts (440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools gone, the desktop-start flow has one stale check instead of two, no startup budget to wait out and nothing to release. A source-scanning census pins it: no module under mobile/src or mobile/app but the one owner imports expo-keep-awake, and nothing anywhere names the retired verb. It reports the file and line, and checks the owner does import the package so the absence is the rule holding and not the match missing. KNOWN RED, reported and not recorded over: 25 golden cases in the speech.* families fail. The recorder adapter had to drop its keep-awake owner, which moves `adapterSha256` for every golden that mounts it, and the deleted owner's id minting shifts the deterministic random sequence, so the recorded `dictationId` values move too. No speech.dictation.* param, reply or operation changed. Awaiting the lead's call on a scoped re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus after the wake-lock deletion Baseline bumped to |
||
|
|
93e4407180 |
fix(build): admit typecheck projects by memory, not core count (#22074)
The typecheck job ran all four tsc projects at once whenever the machine had more than one core. Two of them are expensive -- tsconfig.node.json peaks at 6.3 GB of tsc heap and tsconfig.tc.web.json at 5.6 GB, measured with --extendedDiagnostics -- so together they reach ~14.5 GB on a 16 GB runner. Past that the runner agent is killed mid-check, and the job reports "The runner has received a shutdown signal" with an orphaned tsc, not a type error. Attempt-1 typecheck failures were 0 across Sept 11-17 and then 5-26% per day from Sept 18, with no change to the scheduler in that window. What moved was the codebase: src/main grew 23% and src/renderer 8.5% between Sept 1 and Sept 21, which is what pushed the pair over the line. Projects now carry their measured peak heap and are admitted heaviest-first while the batch fits both a memory budget and the core count, so the two expensive projects never share a runner. On a 16 GB / 4-core runner that plans node+cli+mobile-web (10 GiB) then web (6 GiB), measured at 8.6 GB peak instead of 14.5 GB. A roomy machine still runs all four together, so local typecheck is unchanged. A project larger than the whole budget still runs alone rather than producing an empty batch. The runner body moves behind the standard direct-invocation guard so the admission planner can be imported and tested without spawning tsc. |
||
|
|
8cf0e81ced |
fix(perf): calibrate report budgets without masking latency stalls (#22075)
* fix(perf): calibrate report budgets without masking latency stalls * docs(perf): record historical evidence for report limits |
||
|
|
d40aac0a58 |
test(mobile): repin the session page closure at 4,333 after #21924 reached it (#22067)
C7.7 (#21977) measured the session route's page closure at 4,330 on a merge of |
||
|
|
3cfb070294 |
feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7) The review switch's shape, for its reasons. The session screen becomes `MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would open every one of them behind the page as well as in front of it, since hooks cannot be conditional. As an element passed for `fallback` it is built and not mounted. Four query params carried rather than re-derived, each omitted when empty: `name` is a label the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag, `warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has switched, through `setParams` on the handoff, which inside the page is the document's own router — so it has to arrive in the page for that to happen at all. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it; until then the flag is the only thing that changes and it is off. Three censuses red without their rows, measured on this tree: - `shell-screen-route-census.test.ts` `walks the route tree and finds them` named `session/[worktreeId].tsx` as a ninth switch the list did not have. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`. - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the new sibling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7) The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`, which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went 534 -> 542 on the switch's own param names and path literals. Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The switch's business is which of the two screens renders, not what the session screen does, and its literals have no place in a hash about the extraction. Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks, 77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style references, all at the same SHA-256 they had before the move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7) Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every preference the session screen reads inside the page fell back to its default and kept working outside it — a state the user cannot tell from a preference that does not exist. The keys are derived from the route's own closure, not copied from design §6. Nine join the list: `orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`, `orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names (`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and which the manifest already names as the reason agent-history declares `storage` at all. Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and `isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read the workspace off the route pathname, which is the one fact the shell and the page are each handed. Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is built synchronously, so a write that only reached the store would be one `init` behind. A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store refuses, and the caller that matters already catches: the durable send journal answers "Message not sent" rather than putting a mutation on the wire with an operation id no store holds, which after a crash would send the message twice. `PageStorageRefusedError` names the key and which of the three refusals it was. Measured on this tree, which is why the journal needed more than an allowlist entry: one journal entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past `PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own `BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the *frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value and names it; the page reads a default, which is a degradation rather than a page that does not start. Red first, measured here: - 21 cases across three files on the host-scoped helpers being gone. - `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed. - The journal case reds without the rejection, with the operation claimed against a store that never took it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the session page route (OTA phase C, C7.7) One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this route's own closure rather than carried from design §1. Measured here: navigate 7 handoff sites externalLink 6 openers haptics 24 trigger sites native.clipboard.write 6 sites native.clipboard.read 3 sites native.media.* 2 sites, one seam (`useMediaPicker`) screencastBinary 1 site (`MobileBrowserPane.tsx`) storage 10 exact keys and 2 workspace-scoped, the previous commit's `pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is no second table to edit. The design's list was exactly right; the counts are what say so. The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/ [worktreeId]`, one from each other page route, and none goes the other way: the session's ten grants are a strict superset of every other route's, so every hop into it is handed to the shell and every one of its own targets stays in the document. That second half is asserted as grant coverage rather than as the absence of seven rows — absent is also what an unregistered route looks like, which is the shape C4 already had to correct once. Two censuses gained the route and one is new: - The haptics seam census, whose route-module map moves to `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a second copy that stops growing when the first one does. - `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`: `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is no native chrome behind them, so a bare Pressable is absent from the accessibility tree. - `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the way the haptics census derives its token. C6 could not write it: the pane is mounted by a route rather than registered as one, so there was no route to pin the grant against (C6 ruling 3). The derivation census gains C6's half measured against this route rather than against a module closure read on its own, which is the other half of C6 ruling 3. The composed row for the session route's own families waits on C7.8's table, and on C4.5's split before it. Numbers, both ends measured on this tree, never summed: - Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`. - Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split. - Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side. `mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and `resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached "no output". It tries the sibling first now, which is what the build actually chunked. Without the manifest entry these red on this tree: `pins every hop the handoff must take away from the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure asks for it`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7) The render check mounts the registered route in a real browser on the built bundle, under the header the shells send. It asserts the session screen paints rather than the Unmatched route, that the Back control reaches the accessibility tree as a real `<button>` with its name, that the route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or logs a policy violation, and that the three reads the screen makes carry the workspace the route named — the precondition the rest needs, since a screen that mounted and asked for nothing would paint the same chrome. It also asserts, strictly, that the page and console errors are `[]`, which is what found both fixes here. Measured on this tree before them: two console lines and one uncaught rejection on every mount of the route, none of them visible natively. - `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native Web answers "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the right drawer, the bottom drawer and the file preview already are. There is no hardware back in a WebView; the shell owns the phone's, and the page's Back control is where the prompt lives. - `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch. The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the effect rather than inside the loader, whose promise the golden recorder awaits; notes that did not arrive leave the ones on screen as they were, which is the module's own policy for a refusal. **The terminal is not painted here and the file says so at both ends.** A terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe` stream — five hand-written fixtures against five Zod schemas inside a transport double, which is what the harness's docstring refuses to become. Scripting `status.get` alone was measured here: the protocol gate reads it and the page paints "Update Orca on your computer" instead of the screen. What the terminal does under the shipped header is `mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7) The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are one token each and neither changes what a phone renders: - `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained. - `"button"`, the accessibility role the session header's Back control gained. Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two. Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style references, all at the SHA-256 they had before. A separate commit because a reported head does not move by amend, and because the moved hashes are worth reading on their own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7) The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it today and a fix is a re-record and a review event. Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is green again; it was the one failure in an otherwise clean 8,699-test run. The render check keeps the observation rather than losing it. Its error assertion is now the exact list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second error reds it and so does this one going away — which makes the file the place the fix is noticed when someone lands it with the re-record. The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a *rejected* one, so a host that will not answer raises an unhandled rejection on every session mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and nothing reaches it — so the generation is not dropped and the screen works; the cost is a document-level error on every mount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7) The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on the uncaught `void` call, which is what the tree holds and what the corpus certifies. Count unchanged at 24 effects; nothing else in the family moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1) Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it — `notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key the page was never allowed to keep became an unhandled rejection in the document. That is a worse failure than the silent drop it replaced, and it is the one the page can least afford, because an uncaught rejection there is a document-level error on a screen that is otherwise working. Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it: the durable send journal's composer catches it and answers "Message not sent" rather than sending a mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered` resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus the line a device log needs — a preference that did not stick looks identical to one nobody set. A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize. Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts `PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is visible as the difference between the two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1) Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge took the grant list from ten to fourteen. - Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57` both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside the media ones as things only this route asks for. - Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones", which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`. - The journal entry. 342 and 343 are both real and answer different questions, which is exactly why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the array, the difference being the comma that joins it. 343 is the one that drives the threshold, so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap. Comments only; no behaviour and no assertion moved. The threshold case in `mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and still passes unchanged, which is what says the arithmetic above is the code's and not the prose's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute` is not a member of the host, `onRouteUpdate` is not a member of the page's client, and `ready` carries no `accepts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1. The session switch keyed on the whole route, so a notification tap for another pane of the session on screen either remounted the shell (a bridge teardown and a page reload for a tab switch) or, for the pane already showing, moved nothing at all: the page cleared `paneKey` on its own router and the native param kept it, so `SET_PARAMS` wrote the value already there. `paneKey` leaves the key and travels as a route update. The page declares `accepts: ['route-update']` on `ready`; the shell re-sends `init` for a same-path param change only to a page that declared it, and treats a second `init` for the session the page already holds as a route update rather than a replacement -- in-flight requests, subscriptions, the storage snapshot (the same object, asserted) and the generation all stay. The screen reports delivery and the switch clears the native param, so no later `init` replays a spent tap. `use-notification-pane-navigation.web.ts` reads the request off a standing listener; the native file is unchanged. Wire-compatible both ways without a version bump: `accepts` is optional, an older page is never sent a second `init`, and an older shell never sends one. Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every released native RPC are untouched. Two files were at their line cap, so two modules came out at their own boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of `init`, wanted by the switches, the host and the page) and `bridge-host-route.ts` (one host's held route and what it may publish). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1) Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases the files switch has -- route built, native fallback while the flag settles, repeated params, dot-segment refusal, segment encoding, flag off, remount on a route change, remount on a param change -- plus the two pane cases: a repeat tap for the same pane reaches the mounted page twice and a different pane reaches it once, both with one mount in the lifecycle. The `BackHandler` gate gets a unit test in the shape of its three siblings. Reaching it meant the hook declaring the fourteen fields it reads instead of taking all 268 of the session model, so a probe can render it without building a session; `MobileSessionDiffCommentsModel` satisfies that by construction and the one caller is unchanged. No pin in the session parity census moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1) Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs and the media three were pinned only by the list they were copied from, so striking any of them out of a manifest entry reddened nothing. Each is now derived from the route's own closure by parsing the call sites -- a call, not a mention in a comment or a string, and not an import the module never calls -- and each row has a named control case driven over the session entry with that row's grants struck out. It found one thing. `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose wall offers an Update Orca link through `openExternalLink`, and two routes reach that without declaring `externalLink`: on them the link posts a notify the shell refuses. Recorded exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other routes' grants is a capability decision and this is pre-existing on main. `notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot import its native neighbour by the plain path: the bundler's `resolveExtensions` answers with the `.web.ts` file, so that import was the file itself and esbuild refused the page bundle with a cycle. The mobile suite does not bundle, so only the closure walk saw it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1) The first shape checked the whole manifest at once, so removing any one of the eight reddened all seven cases and named none of them: the per-row control read `session.grants` off the manifest the removal had just changed. Each row now has its own manifest case, and each control is built from what the session route's closure reaches rather than from what its entry declares, so it stays green whatever the manifest says. Closures are memoised, which is what pays for walking all eight once per row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1) The changed-code quality gate's React Doctor found it: `no-adjust-state-on-prop-change`. A tap can arrive before the terminals have loaded, so the request has to wait; holding it in state meant the effect that consumed it set state on a prop change, and the stale selection renders first. The request waits in a ref now and a counter wakes the effect, so the effect reads and clears rather than adjusting anything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1) The full `config/scripts` suite found both. The closure reads 4,326 modules and 984 local, two more than the merge, and the two are named rather than counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane hook's web sibling replaces the native file rather than joining it, so it costs nothing -- but it is a `.web.ts`, so it needs its row in `web-overrides.json` saying why the native one cannot run on the page. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1) Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from `init` did not revoke the page's write access to it: the key stays in `pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)` gave it an empty one, and its first send wrote a one-entry value over the device's -- every native entry lost and a fresh `operationId` for an operation the native journal already held, which is the duplicate send ruling 7 exists to prevent. `pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the value-cap drops, because an entry-cap drop is a key that fits and the page's own write of it is the size the shell would have carried anyway. The shell sends those names as `init.storageOversize`, and a page write to one of them rejects with `PageStorageRefusedError` under the size contract of 33.4, which the composer already shows as "Message not sent". The native journal is untouched until the user is back on native or it drains. `storageOversize` is optional in both directions: an older shell sends none and an older page ignores it, which is exactly today's behaviour. No version bump; omitted rather than sent empty, so no golden moves. Red first, with the two states replaced by ones the shell produces. The 47/48 case drives `pageStorageEntriesForInit` rather than publishing a journal value the shell strips before `publishPageStorage` ever sees it, and the case that used to assert a successful write now asserts the native entries survive: it was the clobber, recorded as success. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2) Round 2, findings 1 and 2, both red first. `onRouteUpdate` fired on every re-sent `init` for the session the page holds, not only on one whose route moved. The shell answers every `ready` with the route it holds and the page re-asks on its own backoff and again after a refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`. Both ends now read one definition of moved, `bridgeRouteMoved`, which is the page's own `shellScreenRouteKey`: the host will not send an `init` for a route that did not move and the page will not publish one it was sent anyway. The `.web.ts` hook keeps its empty-pane guard and its comment now says why it is load-bearing rather than defensive -- the shell's own clear arrives as a move. `onRouteDelivered` ran on the `ready` path without checking that an `init` had gone out. A refused route answers the ask with nothing, so the caller would clear a one-shot param the page never received. `sendInit` reports whether a frame left and `onPageReady` carries it. Unreachable from the session switch, which parses the route before it mounts the shell; the prop's contract says it anyway, and the publish path already honoured it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2) Round 2, findings 4 and 5, neither a behaviour change. The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and its own one-line doc between it and the function, so it documented neither. The type moves above it and the block sits on the function it describes. `web-overrides.json` had an escaped em dash re-encoded as a literal one when this branch added its rows through a JSON round trip, on a line about the keyboard stub that has nothing to do with C7.7. Main's `—` is restored; `oxfmt --check` accepts the file either way, so this is main's spelling kept rather than a formatter's demand. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2) Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of its callers are `void addKey(...)`, so the rejection had nowhere to go. `orca:custom-accessory-keys` is in the session route's page allowlist and a page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the size contract of 33.4, extended by 33.6 to a key `init` could not carry), so past ~16 KB of accessory keys this surfaced as an unhandled rejection in the page -- which the fault boundary reports and which drops the generation. Every other allowlisted writer in this closure already catches: the two write chains in `TerminalShortcutSettings`, the live-input save and the session-view preference. Caught at the boundary and logged, and the drawer neither announces the key nor closes: a row on the accessory bar that no store holds, gone at the next load, is the failure the allowlist exists to avoid. Red first -- the case saw the refusal escape with the page's own message -- and the control reds again when the catch rethrows. Belongs in `8b4c559e90` by the brief; it is its own commit because that one was already made and amending is forbidden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2) CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal and kept the first rejected promise, so a `multiSet` or `multiRemove` with two over-cap pairs built a second rejected promise nobody held -- an unhandled rejection in the page, the outcome ruling 33.4's rejection scope exists to avoid. Two oversize keys is all it takes, and `storageOversize` made a second way to reach it. A refusal is now an error or nothing, and only the caller's one rejection ever becomes a promise. Red first under an `unhandledRejection` listener with two over-cap pairs: one orphan before, none after, and the caller still hears about the first key. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2) CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's key and then published, so a publish the hook refused for having no host was remembered as though it had gone out. `publishRoute` is now keyed on everything the host is built from rather than on the session alone, so the render that brings the host re-runs the effect, and the key is written only after a frame has left. Reported honestly: this does not repair a lost tap, and the case beside it says so. The host is built from the route the render holds, so a route that moved before it existed rides the first `init` either way and `publishRoute` then answers "did not move". What the change removes is a key recorded for a frame nobody sent -- the same contract finding 2 fixed on the `ready` path. The case pins the delivery count across the gap: nothing reported while there is no host, nothing reported once there is one and it has sent nothing, and exactly one report when the `init` answering the page's ask carries the route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2) pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page built with it, and the page is served from the desktop: a document from an older bundle ignores the field and writes the key whole, which for the send journal replaces every entry the device holds. The shell is the half that updates with the app, so the shell is where the refusal has to live. The host now refuses a `storage` notify for a key it could not hand the page, answering it as the drop it already answers an unlisted key with. The page's own rejection stays as the fast path -- it reaches the composer as "Message not sent" with no round trip -- and the schema comment says the field is advisory and the shell enforces it. Red first: a host holding the journal as oversize received a page write for it and posted it to native storage; now it posts nothing and the entries survive, while a key it did hand over is still writable. The three refusals became one predicate in `page-storage-keys.ts`, where the keys are, because inlining the third put `bridge-host.ts` over its line cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2) pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native file's test mounts the native file, and `bridge-route-update.test.ts` stops at the client, so the half that turns a route update into a tab switch was untested. Seven cases: the seed from the route the page was opened on, a request held until the terminals load, a repeat tap on the pane already showing, a different pane, the clear the shell posts after each delivery, a pane that has since closed, and a page opened on no pane at all. Two controls, so the cases are not all satisfied by one behaviour. Dropping the seed reds the two that read the first `init`. Deduplicating by value instead of counting deliveries reds the repeat tap, which is the case the counter exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2) CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists, because a reader is answered from the map rather than from the store and the shell builds `init` synchronously from that map. On a refused write the note stood: the page's next `init` carried the value native had rejected, and every native reader of the key saw it too. The previous mirrored value is captured and put back on the failure path, and the error still goes to the caller so `addKey` keeps withholding the key. Red first: with the store refusing, the mirror held the rejected value where the pre-save value belonged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2) CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed the JSON to `post`, and a rejected post was reported a turn later as a diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page never received, cleared the native param, and the tap was gone. `publishRoute` was fire-and-forget the same way. Delivery is a promise now, settled after `options.post` resolves and false on either throw or reject. Readiness stays separate: `onPageReady` fires on the ask, as the shell's wait needs, and carries the delivery promise beside it. The screen records the route key and calls `onRouteDelivered` only when that promise answers true, and a refusal leaves nothing recorded so the next render that can carry the route tries again. Red first: with the view refusing what it was handed, the frame was built and posted and the screen reported delivery anyway. Now it reports none while the page's ask is still reported, and a host-level case pins the same split. `bridge-host.ts` was at 299 of 300 lines, so the send half came out as `bridge-host-frames.ts` rather than growing it; the file now measures 280. The screen's own test harness never attached the view handle, so every post in it rejected unobserved -- it attaches one now, which is what let the case see the frame at all. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2) pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old `settle` block above `refusalError` when the function it described moved down with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked blocks on `sendInit` after it grew a return value; they are one, and it now says the frame is still built synchronously and only the post is awaited -- which is the property the golden recorder depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the host own a pending route until its frame lands A route the page has not received is now the host's, not the screen's. `publish` keeps it pending until a post resolves true, marks it delivered only then, and reports that through a callback registered once per host. Movement is measured against what a frame actually reached the page with rather than against what the host holds, so a refused frame leaves the route owed instead of reading as one that did not move. Three things the old shape lost, each a case here: a frame the view refused was never retried, because only another render could try and a mounted page has none coming; a render while a post was in flight cancelled the report the switch spends to clear the param; and a repeat tap for the same pane was held, because the host had already moved its held route on the attempt that failed. The retries are the moments delivery becomes possible again — the next `ready`, and a view handle the host regains — and one frame goes out at a time. Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for, and a leftover `an` in the `ready` branch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): roll the journal mirror back when persistence fails `writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in the same turn current — but it kept the note when the store refused. The page then received a journal the device never wrote and resumed operations nothing was holding. Restored on the error path, the same shape as the custom-keys save, and on both halves: the removal that empties the journal had the same gap as the write that fills it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep publishing until the held route is the delivered one Two halves of the same gap, both found by the bots on `fba8cb3f3d`. A route that moved while a frame was out was held for the turn and then had nothing to wake it: the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or tap need ever come along. A landing is now itself a moment to publish again, while what the host holds is not what the page has. Only on a landing — a refused post that re-attempted itself would spin, and that one still waits for whatever makes delivery possible again. And the report carries the route a frame reached the page with, which the session switch was ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed where it was and the second tap was gone. The switch now spends the param only for the pane that was delivered. The delivery cases render through one helper rather than six copies of the same setup, which is what keeps the file under its cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the second block describing a parameter onPageReady no longer takes The field is documented by the block above it; this one still described the `delivered` promise the handler was handed before the host took ownership of the pending route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the page erase the route param it was handed Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param, naming the value the page applied. Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a route it was never given; the notify name is a member of the closed union, so it gets a row in the grant table by compilation rather than by memory, and rides no grant because it can only spend something this shell put there. The shell declares it in `init`, the mirror of `ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the page's check exists from the first version that can post one. The comparison belongs to whoever holds the param, which is the session switch: a tap that moved on while the page was applying the one before it leaves a newer key, and a clear naming the older one is not for it. `bridge-envelope.ts` went over its cap, so the page-to-shell union moved to `bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which the envelope re-exports. No cap was raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): re-send init on a route change and track nothing else Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through the hook and the screen to the switch. What is left is the rule in one line: `publish` sends one `init` when the route moved and the page said it takes one, and every `ready` is answered with the route the shell holds then. A frame the view refused is repaired by the next ask, not by a retry; the request it carried is spent by the page. The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned where they now live: one frame per move and none for a render that moved nothing, a lost frame repaired by the next ask, no second `init` to a page that never said it takes one, and the repeat tap measured through the page's erase rather than through a delivery report. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page applies a pane and erases the request that carried it Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase the param it came on, naming what it applied. Two rules, and both are the page's because the shell has none. The erase is asked for on every `init` that carries a pane rather than only on the one that changed something: a clear that never reached the shell leaves the param in place, and the next frame carrying it is the repair. The switch happens once per value: a re-asked `ready` is answered with the route the shell still holds, and applying that again would drag the page off a tab the user has since moved to. A repeat tap for the same pane still arrives as a request, because the erase went through in between and the tap wrote the param back. The client refuses to post the frame to a shell that did not declare it takes one, which is every shell older than the field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page bundle's readers pointed at the module that declares each name The envelope split left three source-text readers and one import pointed at a file that now re-exports what they read. `shell-screen-route.ts` read the route schema back through the envelope, which reaches that file again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page route mounted onto a schema that was not there yet and the browser render suite failed on twelve files with a TypeError rather than on a build error. It reads the declaring module now. The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by regex; both moved, and a regex over a re-export answers for whichever file the last split left them in. Both point at `bridge-frame-fields.ts`, and the throw names it. The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new modules are named, not inferred: the two halves of the split envelope, and the route-update module and route-key reader the page-to-shell union now reaches through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count posted inits with the page's own reader, not a cast The changed-code quality gate refuses a type assertion, and it is right to here: a frame the page's reader would refuse is not an `init` the page ever saw, so a case counting them must not count one either. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): put every mirrored write on one path that notes what the store took Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on the page a persist can be refused: twelve left the map holding a value no store had taken, and the next `init` handed the page exactly that. Two undid it by hand. `persistMirrored` is the one path now, and it seats the map from what the store holds after the write rather than from what it was handed. That is what makes the note follow acceptance without a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a rejection is not the only refusal there is, and reading back is the only answer that covers both. The cost is one store read per mirrored write on the device, where the store refuses nothing. `writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the shell taking a value the page has already applied, into the device store, which has no allowlist and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so noting on the store's reply there would hand the page back the value it just changed. The last-visited key moved off it, because that module is in the page's own closure. Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A source-scanning census holds each writer to the path by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): answer a page batch write at the first pair it cannot take Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected with the first that could reject while the rest of the batch went in anyway — one promise describing a call where some pairs landed and some did not, which is not something a caller can act on. A batch is one call with one answer now: every pair before the refusal is applied, the refusal is the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or `multiRemove` today, so this is the rule for whoever writes the first one rather than a change to anyone's behaviour; both directions are pinned, including the refused first pair that stops the rest. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the mirrored write path's census and journal writer Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count the note-first callers the mirror module says are held to one Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says the census holds `writeMirroredStorage` to one caller while nothing counted them. Counted now, over every module under `mobile/src` rather than over a list of files a new caller could sit outside of: a second one is either a writer that wants note-then-persist without the store that earns it, or a page-reachable module that would note a refusal as an accepted write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): fold sendInit's doc onto the function it now describes It still described an awaited post that answered whether the page received the frame, which ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all, and a post the view would not take is one diagnostic and no further attempt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page own a frame it received and could not handle Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it again. It is not a lost frame either: the page had it, one of its own listeners failed, and a retry would fail the same way. `receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and the handling is the page's. Nothing is swallowed and nothing is retried. Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug must not wedge, and the bootstrap stamp a tree that throws still leaves behind. With that path closed, a post is refused only when no document holds the view, and the comments on both halves of the route seam say so instead of naming a backoff that is stopped by then. The repair is pinned rather than described: a tap that arrives while the view is gone is carried to the next document's `ready`, because the held route advances on `hold` as well as on `send`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the page's held session out of the client, which was at its cap The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this splits rather than bumps. The session is the one piece of the client with a lifecycle rather than a value: a second `init` for the same session updates it in place, a different one replaces it and takes the requests and streams of the session before it, and each case has its own listeners to fire in its own order. The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are for, and the client's three members delegate to it. The client measures 277 counted lines after the move. The session route's page closure is unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5e25c7a0b1 |
test(frame-budget): raster the noise before the sweep measures a frame (#22048)
The sweep's capture waited two animation frames after painting the noise and then measured the next screencast frame. That is a main-thread commit, not a raster: the screencast hands over whatever the compositor has drawn so far, so after a resize it emits frames at the full size carrying only the tiles rastered yet. Measured under CPU starvation at 1400x1600, 87 of 444 post-commit frames read under the noise floor, one of them 447491 bytes where the full frame is 1221117. A frame that short posts inside the 640 KiB cap, which is what failed "the shell drops what will not fit" with `expected 596462 to be null` on a lead gate. `Page.captureScreenshot` returns only once a compositor frame of the current content exists, so it is the raster the measurement needs. It is ordered on the same CDP session, so every frame counted after it is one the compositor had finished. Over the same rounds with it, none read under the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a3c6d4266a |
fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27) (#21964)
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)
Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.
`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.
No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.
Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct what the sealed preview frame is stricter about
The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.
Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.
Comment only; no behaviour and no test moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure both halves of the preview frame's image fence
"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.
The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.
`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.
Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(scripts): split the preview frame's settling out of the render check
The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.
`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.
Move only. Same 20 tests pass on both engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send Referrer-Policy: no-referrer on the shell document
`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.
`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.
WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.
Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
source the way `readShellCsp` does and throws rather than returning an
empty map. With the value flipped to `unsafe-url` the WebKit arm fails
`expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
the parse throws "could not parse the shell document headers".
The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.
MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): quote the current directive where the old text was written down
Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.
The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the surfaces img-src https: actually unblocks today
The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.
What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.
Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.
That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.
Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the new source-control route pin at the current directive
Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.
Both pins now read the current directive.
One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the admitted images before reading their hits
CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.
So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.
One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.
The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.
What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:
[html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
["/css-bg.png","/img.png","/never-arrives.png"]; #remote
{"complete":true,"naturalWidth":1,
"currentSrc":"https://artifact-images.invalid/img.png?n=n1",
"loading":null}: arm csp=shipped sandbox=product frameReady=images
nonce=n1 | browser 147.0.7727.15 | ... | frames [...]
`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say why an admitted image never arrived, and hand back the context
CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.
Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.
Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.
Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three rationales the widening left wrong
(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.
(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.
(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.
A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.
Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe why Chrome never asks for the artifact image
CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.
On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.
Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.
`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.
The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.
Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): time the artifact image against the frame's attachment
CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.
So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.
Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.
Abort path only; the passing run is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): serve the artifact's https assets from a real TLS listener
Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.
So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.
Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.
The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.
And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.
One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.
24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the artifact fetch and the attachment on one clock
The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.
So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.
The commit message of
|
||
|
|
d199e71a8e |
test(config): wait for the benchmark fixture before timing out its group (#21982)
The owner-loss group case spawned its fixture with a 100ms kill timeout and then read the child.pid handshake the fixture writes, so under full-suite load the launcher was SIGKILLed before it booted and the read failed with ENOENT. Start the launcher detached, wait for the handshake, and only then send the trial timeout's own SIGKILL, which leaves the same orphaned group the cleanup path validates. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5d51bcaf6e |
feat(mobile): serve any manifest route through a [...page] catch-all (OTA phase C, C8) (#21950)
* feat(mobile): add the page-route-unavailable refusal screen The catch-all route landing next has no native screen behind it, so its fallback cannot be a panel. Nothing imports this yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move firstParam out of the source-control tree Every caller is a route file under app/h/, and the import dragged mobile-git-status.ts and the screen-state module into the closure of any route that reads a param: 1989 modules (3 local) for a one-line helper, against 1 from src/navigation/route-param-reader.ts. Pure move. The three shell route suites drop their lucide-react-native mocks with it; that barrel was only ever reached through the old home. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve any manifest route through a [...page] catch-all Every page screen needs a route file under app/h/[hostId]/ today, so a screen the desktop registers after a store build has nowhere to mount and expo-router paints Unmatched. This adds one catch-all that hands any host-scoped pathname to the shell; the manifest still decides, through the same routeViewOf the other switches reach. Measured with expo-router's own matcher on both platforms: every route that has a file keeps it, index and the four .web.tsx siblings included; only pathnames that reached Unmatched move. The body lives under src/ because expo-router 55 reads a file's platform from the first dot of its stripped name: [...page].web.tsx under app/ parses as platform '' and registers a second route rather than overriding the first. Under src/ the stem is plain and Metro and the page builder both resolve the sibling. getRoutes shows exactly one [...page] key on each platform. Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page refuses an unrouted host path instead of Unmatched The catch-all owns every /h/<id>/... pathname with no module, on the page as well as in the app, so the render check's unmatched case can no longer pass: measured in headless Chromium, /h/<id>/not-a-route paints the refusal with no page or console error. bridge-caps.ts records that C8 closes the C1.7 class for the host subtree, and its dot-segment note is rewritten to the measured mechanism: getStateFromPath normalizes the href through new URL(href, 'file:') in getUrlWithReactNavigationConcessions before cleanPath sees it, so /h/..?x lands on the app's root screen rather than on a host screen with hostId '..'. Refusing it stays correct. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the web-overrides allowlist in append order The catch-all entry was added with a whole-file sort, which rewrote 116 lines for one addition and buried it. The test compares sorted sets, so the order on disk is free; append order is what makes the diff readable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): leave the refusal on the encoded host route The Back href was a raw template, so a host id carrying a slash built /h/a/b — two segments, which the catch-all that rendered the refusal matches with hostId now "a". The control looped back into the screen it exists to leave. hostStackHostRoute already encodes it and is what the notification path pushes through. hostId is a string: firstParam returns one, so the undefined arm and its ?? '' were unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the catch-all's fallback binding and its way out catch-all-page-route.test.tsx mocks both the shell and the refusal, so fallback={refusal} was unpinned: mutating it to null left that file at 10 passed. This drives the other half with the real shell screen and the real refusal under it, stubbing only what the session reducer settled on. Four cases, each measured against a mutant: fallback={null} reds three, push instead of replace reds two, a raw /h/${hostId} template reds the five-shape encoding case. The checking case is the presence precondition the rest need: before the flag read settles the switch returns the refusal on its own, with the same text and the same control, so an assertion on the refusal alone would pass against a screen no shell ever rendered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the route-body follower resolve every shape or fail The follower read only `export { default } from 'x'`. A route file doing `import X from 'x'; export default X` and mounting the shell without shellScreenRoute left the census at 5 passed: the body was never opened, so the file read as "not a switch" — the one answer a census must never give by default. Both shapes are followed now, and an unresolvable one is named rather than skipped. Measured against four mutants: a shellScreenRoute call dropped from the re-exported body reds the rule; an import-then-export route mounting the shell reds two cases naming the file; a default from a package specifier, a file with no default, and a re-export with no module each red the new resolution case with the reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the host-node lookup in the catch-all state test The tests-typecheck ratchet reds on findAllByType with a host string: react-native is mocked to strings here, which is not an ElementType. A findAll predicate on node.type is the same lookup and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the refusal does not absorb the other shell states The refusal answers one question — this build cannot serve this route — and offline, checking and the protocol wall answer different ones that are each true for a screen only the page has. Absorbing them would tell someone with no connection that the screen does not exist. Written where fallback is bound, and driven: an offline session through the catch-all paints the connect message. Mutating the shell to return fallback for offline reds that case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the type the shell-view probe stands in for The anti-slop gate refuses a broad `object` parameter, and it is right here: the probe forwards every prop to its host node, so the type it accepts is the view's own. Type-only import, so the module's requireNativeViewManager call is still never evaluated. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send the refusal to the app root when it has no host firstParam answers an absent hostId as '', so Back called hostStackHostRoute('') and landed on /h/ — which expo-router's own matcher resolves to the h layout with no child, a press that paints nothing and leaves the dead end in place. The app root lists hosts and is where ProtocolBlockScreen sends the same gesture from the same position; the label follows the target rather than outliving it. Also through useRouteHandoff rather than useRouter, which is the same defect on the other side: the page renders this screen through the catch-all's .web.tsx sibling, and there a bare replace navigates inside the WebView to a route the page does not carry instead of leaving it. ProtocolBlockScreen already uses the seam; the router-seam censuses cover src/session, src/files and src/source-control, not src/mobile-web-shell, so nothing caught it. Costs one module in the page closure (host-stack-navigation.ts): every module the seam reaches is already in the layout's closure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): repoint C4.4's source-control web sibling at the moved reader The merge brought in a route file that imports firstParam from the source-control screen state, which this branch emptied. Git merged both sides cleanly because neither touched the other's lines; tsc is what catches it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mock dictation's device half in the catch-all state test The merge put the audio verbs in the shell screen's closure, so this file reaches @orca/expo-two-way-audio, whose module touches the Expo global at import. Same two mocks MobileWebShellScreen.test.tsx carries for the same reason; what each verb does is bridge-audio-verbs.test.ts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the router seam under mobile-web-shell The three landed censuses walk src/session, src/files and src/source-control, which is how PageRouteUnavailableScreen shipped with a bare useRouter and nothing caught it until CodeRabbit. This tree needs a shape of its own because it holds both halves: the rule cannot be "no router" when MobileWebShellScreen and useShellStackPop are the app end the page's navigate and navigate-back notifies arrive at. Both are named with the reason, and neither has a .web.* sibling, so neither runs inside the page. Red first: with the bare useRouter put back, two of the four rules fail naming the file — + "PageRouteUnavailableScreen.tsx (useRouter)" - "PageRouteUnavailableScreen.tsx" The walk covers 72 product modules, asserted above 60, so the empty finding list is over a non-empty walk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d9954000b3 |
refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969)
* refactor(mobile): split the rich editor document's stylesheet and markup apart The body constant carried the tail of a `:root` block, every CSS rule and the editable surface's markup in one string, which only the HTML builder could splice. A page mounting the document needs the stylesheet and the markup separately, so they become a function over the theme and a constant. Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of the shipped document is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the keyboard-inset normaliser its own module It is the host's half of the inset, read by the controller, and it sat in the module holding the document's in-page script. The script is about to become ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does not belong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich editor's document becomes scope-threaded modules and a factory The editor's ~600-line program lived in seven string constants a concatenator glued into one `<script>`: unreadable, untypeable, and unreachable from a page, which is where the OTA shell has to run it (ruling 26). It is now ordinary TypeScript under `src/components/rich-markdown/`. Every function that touches editor state takes `scope: RichMarkdownEditorScope` first, `createRichMarkdownEditorDocument(host)` builds the scope, runs the start sequence and returns `{ send, stop }`, and the six window reads the script did are host seams with those reads as their defaults: `postToHost`, `promptForUrl`, `keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`. `runCommand` is async because a host that answers the URL prompt with a modal cannot answer synchronously; the thirteen commands that never wait stay one synchronous act. No module holds a `let` and none does work at parse time (rulings 20, 21), so a second mount starts from its own state and `stop` takes back both the surface's four listeners and the viewport's two. The native document is an esbuild IIFE bundle of `native-document-entry.ts`, written beside the terminal document's artifact by a fifth postinstall generator. Nothing ships it yet: the HTML builder still splices the old strings, which the next commit changes. Red-first: `rich-markdown-document-parse-time.test.ts` and `rich-markdown-host-seams.test.ts`. Their readers are the terminal census's, extracted to `src/test-support/webview-document-census.ts` and pointed at both documents rather than copied. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ship the bundled document and retire the editor's script strings `buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of `src/components/rich-markdown/`, and the seven string constants and their concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for `injectJavaScript`, which is still how the native host reaches the document. Equivalence, since a byte golden over the script cannot survive a bundler: - `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact exactly as the WebView does — its markup, its bridge, its `execCommand`, its `prompt`, its `visualViewport` — and drives it through the injected handle: `keyboardInset` then `ready`, all five members, a markdown round trip through the real escape, an edit under the host's generation, every toolbar command's engine verb, the `javascript:` refusal, a tapped link, and the module list. - `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the page around the document. Measured on main's own document with its script region removed and on this one: 5,621 bytes, both `5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The whole-document digest it replaces was `1ef29c88…`, 29,852 bytes. Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting functions out of the emitted text is kept, aimed at the modules: - nested/ordered/task list rendering and serialization, entities, explicit numbering, the parent-start fallback, read-only checkboxes → `markdown-round-trip.test.ts`, over real elements rather than shaped objects. - the emitChange/setEditable guards and the generation carried through a replacement → `editor-content.test.ts`, behaviourally. - dismissKeyboard, the tapped caret, the label tap, the restored caret, the end-of-document fallback, the detached caret → `editor-selection.test.ts`, with a blur that drops the ranges the way WebKit does. - parseable script and the injection escape stay in the HTML test. New with the factory: `document-lifecycle.test.ts` — stop takes the four surface listeners and the viewport observer off, a second mount is its own document, two documents do not share `editable`, and a start that throws unwinds. `use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the web fallback tests are untouched and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the document's mutable bindings from the tree, not the line start The census matched `/^(let|var) /gm`, so `export let`, a declaration indented inside a top-level block and a `for (let …)` head were all invisible — three shapes of the one binding two documents would share — and its single precondition proved only the shape it could already see. `moduleLevelMutableBindings` walks the program instead and stops at every function body, because a binding one call owns is not module state. Its preconditions are one per shape, with the kind each reports, and a negative case over a `const` and a function-local `let`/`var` so the empty list is a measurement rather than a reader that refuses everything. Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it with `keyboard-inset: let pendingReport`, which the old matcher passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin both WebView document bundles to the mobile root esbuild writes each module's path into a bundle as a comment relative to the working directory, and neither generator set `absWorkingDir`. So the artifact's bytes followed the cwd of whatever postinstall run wrote it: measured from the repo root, `mobile/`, and `mobile/src`, three digests — and from outside the repo the comments carried `/Users/<name>/…`, a machine path in the one file every bundle test compares against a build it makes itself. Both generators now pin the mobile root, so the four cwds measured agree, and both bundle tests carry the pin: a digest built in a child process from the OS temp directory equals the committed artifact's, and no comment in either artifact is an absolute path or climbs out with `../`. `build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied its shape, so both are fixed here rather than leaving the original to be found again. Neither artifact's bytes move: both were generated from `mobile/`, which is what `absWorkingDir` now names. Red-first: deleting the `absWorkingDir` line from either generator reds that generator's case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the getSelection seam's override, not just its default Five of the six seams had both halves and this one had only its window default, which is the half that cannot fail on the page: there the caret has to come from the object the host hands over, because a document mounted inside a screen shares `window` with every other field on it. The case gives the document a selection of its own, blurs the surface the way WebKit does — dropping the ranges, which is the whole reason a caret is saved — and reads the restored caret back out of the host's object. The window's own selection stays empty throughout, which is what says the default was never consulted. Red-first: `rememberSelection` reading `window.getSelection()` instead of the field reds it; every other case in the file stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor document's stop cancel its pending timer `stop` took the surface's four listeners and the viewport's observer off and left the input timer, while the scope kept the handle and the `clearTimer` seam kept the means to cancel it. A listener comes off with the element it was on; a scheduled callback holds the scope and fires into a document the host has already unmounted, posting a change under the generation of content it has replaced. `stopEditorContent` cancels it through the seam and clears the field, and the sequence runs it last — after the listeners that could have scheduled another one are gone. Nothing schedules the handle today. The cancel is here because the seam and the field exist for the day something does, and that is not the moment to discover `stop` never reached it. The case plants the pending change rather than waiting for a debounce, and carries its own control: the same timer posts while the document is running, and posts nothing once it is stopped. Red-first: dropping `stopEditorContent` from the sequence reds both that case and the parse-time census's start/stop set comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the postinstall generator count in both censuses Two comments said four generators and six generated files. There are five generators writing six files, and the six are not the six either comment described: `census-source-files.ts` still named the page's copy of the terminal document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1 added the rich Markdown editor's. Both now name the lists of record — `mobile/package.json`'s postinstall for the generators, `mobile/.gitignore` for the files — and say the count is a reading that grows rather than a fence, which is what made the old numbers wrong twice over. Verified against both lists: 5 and 6. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which digest is the document and which is the page around it The docstring put main's whole-document digest and byte count in the sentence introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were what the constant below asserts. They are not: that digest is of main's whole document, script included, and nothing in the file reproduces it. The constant is of the document with its `<script>` region emptied, taken on main's document and on this one. Both are now named and separated, with what each covers and why the shell one was read twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the parse-time fixture by its role Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an editor command whose dialog answered after the host moved on C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a modal. Inside the WebView that changes nothing — `window.prompt` resolves within a microtask, and the host reaches the document through `injectJavaScript`, which is a later task — but on the page the modal is a real task boundary, and while it is open the host can replace the content, make the editor read-only or unmount it entirely. The continuation ran anyway: `createLink` against markdown nobody chose, and a change posted under the new generation carrying an edit made against the old one. `acceptsCommands` is the question both halves ask: not stopped, still editable, still the same generation, still contenteditable. `insertUrl` asks it before `execCommand` and `runCommand` asks it again before emitting, each against the generation read before its own wait. The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets. Inert on native, where no state can change across a microtask, so the answer to both questions is the one the old code assumed. Red-first: with either check removed, the new case reports `[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its own control — an answer that arrives while nothing has moved is still applied and still reported. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor's block reader always consume a line `markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a marker followed by a space, and the list test admits the same, but the heading reader requires text after the hashes and `parseListLine` requires text after the marker — so on those lines the list branch consumed nothing and returned the index it was given, and the paragraph loop gathered nothing and pushed an empty paragraph without advancing. A one-line file the host handed to `setMarkdown` froze the WebView. Two guards, both by the same rule: a branch may only commit if it moved the index. The list branch falls through when its run is empty, and the paragraph falls back to the line itself when it gathered none. Present on main verbatim, so this is inherited rather than introduced — but the fix is observationally inert, because the only inputs it changes are the ones that previously never returned. Every input that produced output produces the same output. Evidence, from a probe that bounds the loop from the inside rather than waiting on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence shapes all return. The pinned cases carry their own control, `# ok` and `- ok`, so the fallback is not swallowing the readers it falls back from. A red-first case is not possible here: without the fix the case does not fail, it hangs the worker. The probe above is the measurement. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): see every declaration that runs as a document module is evaluated The parse-time reader inspected only variable declarations, while `DECLARATION_KINDS` admits classes and default exports. So `class A { static value = install() }`, a static block, and `export default install()` all passed a census whose whole job is to refuse exactly that — and a static field reading `document` passed too, which is the remount defect the rule exists for, wearing a different shape. Three shapes now, each reported by what it does rather than what it looks like: a variable initialiser, a class's static members, and a default export that is an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into the body of `export default function () {}`, whose calls run when something calls it. The preconditions are one per shape, with a negative case beside them: an instance field runs per `new` and nothing in a document is ever constructed, and a default-exported function declares a body rather than running one. Inherited from the terminal's census, which had the same reader; both use this one, and both are green. Red-first: removing the class branch reds the new precondition case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read lifecycle exports from the tree, not from one exact spelling The reader was a regular expression needing `export function`, one line, the scope parameter and no return type. `export async function startX(`, a return type, or a parameter list the formatter wrapped made a real lifecycle export vanish — and the comparison it feeds is a set against the names the sequence calls, so a function missing from *both* lists makes them agree. A start nobody runs would have read as a start nobody needs. It now qualifies a function by what it is: exported, named for its lifecycle, and taking the document's scope as its only parameter. That last clause is ruling 20's own wording — a start takes nothing the scope does not already carry — and the regex was enforcing it by accident, through the single parameter its pattern happened to allow. Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the regex never matched and the sequence never calls. It takes a direction, so it is the overlay's act for a drag rather than a module's lifecycle, and the one- parameter rule refuses it for the stated reason instead of by accident. Both censuses are green. Red-first: restoring the regex reds the new precondition case, which covers `async`, a return type and wrapped parameters, with refusals beside them for a two-parameter start, another document's scope type, and an unexported function. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a91ca8b19e |
feat(mobile): dictation on the OTA page over native audio verbs (Phase C, C7.10 PR D) (#21905)
* feat(mobile): serve dictation capture over four native audio verbs The page owns dictation's state machine and speaks `speech.dictation.*` to the desktop, where transcription runs; the microphone is the shell's. So the shell gains `native.audio.start|read|stop` and `native.wakelock.set` — four rows, four grants — and rings what the microphone produces at the page's own pending-audio budget rather than pushing bytes the page would hand straight back. `native_audio_not_capturing` joins the refusal vocabulary: a read for a capture this session does not have is the one refusal the page must tell from a device that failed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take dictation capture through one seam on both hosts `use-mobile-dictation.ts` held the microphone and the wake tag directly, so the page had a hook whose every device call was a stub answering denied. The five calls move behind `src/platform/dictation-capture.ts`: natively the same calls in the same order, on the page the shell's four verbs, with the drain raising the events the engine emits. The tag bookkeeping stays where it was and stops importing `expo-keep-awake`: two calls come in through the seam and the pools, the queue, the timeouts and the retries are the same on either host. The chunk sender is untouched. A chunk carries raw PCM because that is what the budget counts and what `speech.dictation.chunk` is built from, so the page pays one decode of 32 KB a second rather than the flow carrying two shapes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): drain the shell's audio ring on a 500 ms batch One `speech.dictation.chunk` per native microphone event is 31.25 forwarded requests a second, and each holds one of the bridge's 64 in-flight slots for a whole desktop round trip. Measured over ten seconds against a two-second link: 63 in flight at the peak and one slot left for the rest of the page. Drained every 500 ms instead: 5 in flight, 60 slots free, the same 42 KiB/s, and 38 frames out and 34 back for the whole session. The frame cap was never the bound — half a second of PCM is 3.3% of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive dictation's grant rows from the route closure Which routes need the four audio verbs is a census, not a hand list: the rule reads each registered page route's own closure and holds its `grants` to what that closure reaches. Vacuous on today's route list — the session route is the only closure carrying the seam and is not registered yet — so a control runs the same rule against the session module and names all four as missing. The closure also records what the seam took off the page: `@orca/expo-two-way- audio` and `expo-keep-awake` are gone from it entirely, and removing the web file puts four of the vendored stub's modules back. The mic control's render case found a real one. A start the shell refused outright left the button on "Starting voice dictation" with no way back, which is every tap on a route without the grants. It reports the refusal and returns to idle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the mic control's render case inside the tests typecheck A `let` the renderer assigns inside a callback narrows to `never` afterwards, and the mocked `Pressable` took `children` as `unknown`. Both are type-level only, and the ratchet is the gate that notices: a test outside `tsc` can pin a shape that stopped existing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand over the audio still in the ring before stopping the shell `end` cancelled the drain and stopped the capture without a last read, so up to one drain interval of the utterance's tail — 16,000 bytes, the 400 ms a user is still speaking as they lift the button — was discarded on every stop. The reviewer's probe spoke 12,288 bytes in the last 400 ms and the page delivered none of them. Natively that audio is already in the hook's hands, so this was a page-only loss of the end of every sentence. `end` is now asynchronous: it cancels the timer, waits for any read in flight, reads once more, and only then stops the shell — stopping first takes the capture away and the read after it is refused. `stop()` awaits it before it stops accepting chunks and before it takes the pending set, or the tail would be dropped one line later and `finish` could overtake the last send. A release still skips the last read: the screen is going away and there is nobody left to hand the tail to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release the page session's wake tag when the session ends The wake-lock server held its tags per instance and a new one was built per page session with nothing ever disposing it, so a tag a session took was never given back and the screen stayed awake for the app's lifetime. The page is a document that can navigate, fault or be swiped away mid-dictation, so nothing else was ever going to call deactivate. Its own docstring claimed the opposite. It now answers `{ serve, dispose }` and is disposed with the session exactly as the microphone and the staged media handles are. `dispose` drops only what is still held, so a tag the page already gave back is not deactivated twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the capability flag no screen reads `canCaptureAudio` answered four grants to nobody and had no test. The fence that actually holds is the per-verb `ungranted` check every member already makes before a frame is sent, and the mic control's render case pins what a screen does with it. The surface's member list is pinned instead, so the next flag with nothing behind it has to be added there on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the closure the dictation census records, and make its controls real Three corrections to the census, all of them about the census lying rather than the product being wrong. The recorded number was four modules; measured on this head it is eight — five from `@orca/expo-two-way-audio` and three from `expo-keep-awake` — for a net +7 once the local file that left is counted. The absolute closure counts are provenance in the docstring and are not asserted, because every merge of main moves them. The absence now has a precondition: both package names are resolved from the install, so a substring matching nothing fails as a typo. The case named "reads the census file" read no file. It reads the shell's own verb table through `import()`, behind the closure guard, so a grant the shell has no row for reds instead of agreeing with itself. And the grant control re-implemented the rule's filter inline. Both the rule and the control drive one function now, over the entry C7.7 would write if it copied its neighbours' grants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serialise the shell's audio starts and stops Two starts racing the OS permission prompt — a page reloaded while it is up, which is the case the replacement rule exists for — both reached `listen()`, and the second overwrote the first's handlers without removing them. The engine went on calling into a capture nobody could read, and `dispose` freed one of the two. A stop that overlapped a start found nothing to end and the start opened a microphone behind it. Starts and stops now run one at a time in the order the page asked for them, and a start that comes back after the session ended opens nothing. Reads stay off the queue: they must not wait behind an opening capture, and a read with no capture is already a refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a capture on the same two interruptions on both hosts The page treated any interruption as the capture being taken away, while the native seam has always gated on `began` and `blocked`. So an `ended` on its own — the OS handing the session back after a notification chime — cancelled a live dictation on the page and did nothing natively. The rule is now one predicate beside the vocabulary it belongs to, read by all three places that decide it: the shell, which stops filling its ring; the native seam, which raises it off `onAudioInterruption`; and the page, which raises it off a read reply. `recording` still ends the page's capture whatever the kind, because a capture the shell no longer has is gone however it went. The native half had no test of its own, which is why the drift was invisible. It has one now: the five calls it makes, the chunk it hands over, the wake tag, and which interruptions end it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): type the chunk sender as what its callers pass The sender's parameter was the native `MicrophoneDataEvent` though both callers hand it a `DictationCaptureChunk`. Structurally the event is the wider type, so it accepted either and read `droppedBytes` off neither — a page whose audio the shell's ring had dropped would have sent it as though nothing were missing, and nothing would have failed to compile. Typed as the chunk, with a compile fence beside the seam holding both directions: a chunk is accepted, an event is refused, and a raw buffer is not a chunk. The seam normalises the bytes, so the widening the sender did has no caller left. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the session closure with dictation's page modules on it The capture seam moves this count down rather than up. Measured at |
||
|
|
76439ef00d |
test(mobile): put the frame-budget noise floor between the two encoders (#21972)
The sweep asserted that its minimum noise cost stays above 0.5 bytes per pixel, a floor chosen against the pinned Chromium's 0.543986. The runner's Chrome 152 encodes the same seeded noise at 0.480898, so the floor sat inside the spread between two encoders and failed a green run on a measurement that was noise. The averaged-noise arm the floor exists to catch reads under 0.3 on both. The floor is now one named constant at 0.4, used by the sweep and by the arm that proves the floor catches an averaged canvas, with both readings recorded beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
963da57cc3 |
test(config): preview rig readiness polls the main world, never the utility world (Chrome 152 hang) (#21963)
* test(mobile): wait for the preview frame in its main world, and probe the world that hung
Three cases spent their whole 180s on CI's Chrome inside `waitForSelector('#marker')` while
the diagnosis reported, from the same frame, `readyState: complete` and `marker: true`.
Those two readings ask in different worlds. `frame.evaluate` needs only the frame's main
execution context; a selector wait needs Playwright's injected script in Chromium's utility
world, an isolated world created per document by a command whose failure the driver swallows
and whose creation event it drops for a frame the driver considers stale. With `timeout: 0`
a world that never arrives is a wait that never ends.
So readiness is main-world polling now: the frame is resolved again from `page.frames()` on
every attempt and the predicate runs through `frame.evaluate`, still bounded by the case's
own `ctx.signal` and still ending in the diagnosis. The evaluate is abandoned after a second
so a frame that never answers cannot outlive its own replacement.
The diagnosis gains the reading that would have settled this in one run: a bounded
`utilityWorld` probe per frame, printed beside the main-world reading, so the split is
measured rather than inferred again. The competing explanation is ruled out in code --
Playwright closes a detached frame's scope with an error that every wait races, so a stale
Frame rejects rather than hangs.
Not proven red-first. Chrome 152 is the only engine that has shown this and it is not
available here; chromium 147 and WebKit 26.4 both build the utility world and both report
`utilityWorld "resolved"` for the sealed `srcdoc` frame. What is proven locally: 18 of 18 on
both engines, and a deliberately marker-less artifact still ends in the diagnosis, with
exactly one line per case naming the wait that hung.
That last part needed a fix of its own: an abort listener left behind by a wait that had
already resolved printed its stale reading at a later wait's timeout, so every timeout spoke
with more voices than it had hung waits. The listener is dropped on the way out.
In-frame `frame.click` needs the utility world too and is left alone: a main-world click is
not a user gesture, and the gesture is what those cases assert on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the preview's refusal from the browser's report, not from a listener in the frame
The utility-world theory is refuted: CI's Chrome answered `utilityWorld "resolved"` on both frames
and the case failed anyway, with the widened frame reporting the artifact parsed, the CSS
background's `img-src` refusal recorded, no `script-src` refusal, and no script run. Two different
things produce exactly that reading. The policy refused the script and the frame's own listener was
not there to see it, or the sandbox refused it first, which raises no violation at all -- and a
listener inside the frame cannot tell them apart, because in the second case there is nothing for it
to hear.
So the evidence moves to where neither depends on timing: the sealed server now appends `report-uri`
to the policy it serves, carrying the arm's nonce, and the rig records what the browser reports. The
override arm's precondition is a `script-src` report from this arm's frame, waited for under
`ctx.signal` and ending in the diagnosis. Measured on both engines: a widened frame is reported for
`script-src` and a sealed one never is, while both are reported for the image the policy refuses. So
the sealed arm now waits for its own `img-src` report, which turns "no script-src refusal here" from
an unguarded absence into one measured beside a presence.
`report-uri` is additive -- it names where a report goes and changes nothing about what is enforced
-- and the first case now pins that by splitting the served header and asserting the rest is the
shipped Kotlin text exactly.
The in-frame collector stays, for the diagnosis only, and it now carries the readings that would
have answered the ordering question in one run: the init script records when it ran in each frame,
the artifact's script records the same on the document element, and the diagnosis prints both. What
the artifact wrote moved off `window` entirely for the same reason -- a page init script owns the
window of every frame it reaches. Locally the init script precedes the artifact's by one
millisecond, in every arm on both engines; the ordering on Chrome 152 is now a reading rather than a
hypothesis.
A measurement worth keeping beside the code: in a frame with no `allow-scripts` the init script runs
and its array exists, and no violation event is ever delivered to it, while the browser reports the
same refusals to the server. That is why the old `violations` assertions could not have caught this.
Red-first, all three locally: with report recording off, with the report endpoint not appended, and
with `script-src` reports alone dropped, the preconditions time out into the diagnosis and the
served-policy assertion reds too. 18 of 18 on both engines, three runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): cover the navigation wait's sampling branch, and bind the load wait to the case
Two findings from the bots on the rig, both real.
The navigation wait's five-second sampling branch called `describePreviewFrame` after the import
that supplied it had gone. It fires only when an arm is slow, the name is evaluated before `.catch`
can attach, and `no-undef` is off, so nothing in the file or the lint run had ever executed that
line. Fixed by moving the settle waits into the readiness module, where the call sits beside the
import it needs rather than a file away from it -- the split is what let the reference dangle.
The proof is a case that drives the branch: a navigation the arm will never see, a sampling interval
passed in, and the case's own abort ending it, asserting on the reading it printed rather than on
its own absence of an error. Red-first, with only that branch's callee renamed: 2 failed, 18 passed,
`ReferenceError`. So the case covers the branch and nothing else in the file did.
The load-only arm's `frame.waitForLoadState('load')` was the one wait left that did not observe
`ctx.signal`; after an abort it kept waiting on its own timeout. It is a main-world poll on
`document.readyState` now, re-resolving the frame each attempt like every other wait here, and it
ends in the diagnosis.
20 of 20 on both engines, twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
5fed670a50 |
fix(rebuild): refuse to compile node-pty source that lacks the MSYS breakaway denial (#21968)
The addon gate already rejects a conpty.node without the L"msys-2.0.dll" marker, in the Electron probe and after the rebuild. But the rebuild compiles whatever node_modules/node-pty holds, and pnpm only materializes that from the patch at install time. On a Windows dev checkout whose node_modules predated the denial, --force compiled for minutes, rewrote conpty.node byte-identical and unpatched, and the gate then advised "rebuild from source" -- the step that had just run. Read src/win/conpty.cc before compiling. If it lacks the literal, stop before the rebuild and say to run pnpm install, which re-applies the current patch. An absent source file is not judged; the addon gate still reads the binary. |
||
|
|
23207bfde2 |
feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) (#21957)
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4) The review route file called `useMobileDiffReviewController` at its top level. A switch cannot keep it there: hooks are unconditional, so the whole controller — its client subscriptions included — would run behind the shell's page whenever the shell renders. As an element passed for `fallback` it is created and not mounted, which is how the explorer switch already behaves. `useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left in the review closure (measured: the only other value import of one is the seam's own web sibling), and inside the page the session screen `openSession` replaces to is native, so that target has to be handed back to the app rather than posted into a document that does not render it. The params are read in the component rather than handed down, so this is the route body and the route file above it is free to become a switch. `session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census reds twice — `imports nothing from expo-router that can navigate` names `MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which is what forces the swap rather than leaving it to a reviewer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4) Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute` as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason — the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the route manifest imports every route. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it, which is the next commit; until then the flag is the only thing that changes and it is off. Query params are omitted rather than sent empty, and the whole record is omitted when none was named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`. `pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a redirect inside the page would leave the session bound to a pathname the page has left; left native they replace into this route and its switch mounts the shell. Three censuses red without their rows, measured on this tree: - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new siblings; `states a reason for every override` reds on a placeholder under 20 characters. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without the two `SWITCHED_ROUTES` names. - `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two switch names. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push review and review replaces back, and a target declaring no more than its opener is a hop the handoff keeps inside the document. Registering either alone would have put a native frame and a second bridge session between a changed-file row and its diff. `pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the derived target list at this tree is [files, files/preview, source-control, [p], accounts, agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads call sites and both redirects name `source-control`. Measured on this tree, not carried from the draft: - The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history, files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review` is absent in both directions, which a new case now asserts as grant-list equality rather than as the absence of a row — absent is also what an unregistered route looks like. - The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing. - Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map. Without the two manifest entries these red on this tree: `pins every hop the handoff must take away from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, and both haptics-seam cases. `build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at 607 non-comment lines against the 600 cap, and the declaration block is a different concern from how the bundle is built — it grows once per registered domain while that file does not. It moves whole into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route to register does not have to choose between a lint fence and a split it did not ask for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4) The render check mounts both routes in a real browser, asserts each paints its own screen rather than the Unmatched route with no console error and no page fault, and asserts each fetches its own chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the origin or logs a policy violation while it paints. The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar `View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch is pinned by a component test, which reds on the platform check being removed. The render check's off-origin case is honest about being the negative half only — no comment card renders there, because the PR chain behind it is not scripted, and the file says so. The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which `RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it is added. The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal: 'oversized' }`, and the page's promise never settles — `busyAction` never cleared and `setActionError` was never called. Both new cases red by timing out at 15s against that path. Refused at the page's own send boundary instead, under the shell reader's own predicate rather than a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a `BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so nothing ran on the desktop and the smaller retry is safe to offer. The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and that the busy flag is raised and then cleared. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4) The changed-code quality gate named five, all in the files the previous commit added, and a fence is not the answer to any of them. A separate commit because a reported head does not move by amend. - The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing. - The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a component. Through `String`, because `node.type` is `ElementType` and React Native declares no intrinsic elements, so the compiler reads a bare tag comparison as unreachable. - The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed. - The rejection and the diagnostic are read through narrowings instead of casts, which also drops an `expect.any` that only type-checked because of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1) Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three things followed, all measured here on a cyclic `params`: - the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the `BridgeSendFailedError` every other undelivered frame raises; - no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost; - `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent no such frame. Sixty-four of them and every later request is refused with nothing to say why. `posted()` carried the same escape into the members whose contract is a boolean, where a throw is worse still: those callers are taps and teardowns with no catch on them. Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption that broke; it now says why the call sits where it does. The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both changed cases red on the serialization moving back out — `expected 'TypeError' to be 'BridgeSendFailedError'` and `expected 63 to be 64`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1) Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing `isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85 of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1 would then be posted and silently dropped — the hang the refusal exists to end, back for every frame in that one-unit band. Two rules, because either alone passes against the defect: - The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and is the same string the reader answers `oversized` to. An off-by-one reds the second. - The census. The client reaches the cap through the shared predicate and does not name `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that parses inbound frames. A private copy that is correct on the day it is written reds here. The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an envelope that grew a field. Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and the census failing to find the predicate — while the rest of the suite stays green, which is the finding reproduced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1) Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound frame on `json.utf8.count` (`MobileWebShellView.swift`, through `MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size` (`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`. UTF-8 bytes on each platform. The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the `raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too. The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports `utf8ByteLength(json)`, and the type says which unit that is. Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units and over it in bytes, plus a source case reading the measuring expression out of each shell. Three mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit fixes, in the number it would have printed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1) Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted, which reads as a proof of the web skip and is not one: no comment card renders on either page, because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed. Deleted rather than propped up. Giving the page a presence precondition means five hand-written fixtures against five Zod schemas inside the shell double, which is exactly what the harness's docstring says that double must not become. So the only proof of that branch is `pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now says so in its header instead of implying otherwise. What survives is a property of these two closures rather than of that component: not one request leaves the origin while either route paints, and nothing either paints violates the policy. That one can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output. The CSP half is unchanged and was never in question: the served header is read from the Kotlin source and the Swift twin is pinned beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e6de7a4bbe |
refactor(mobile): thread the scope through the terminal document and bundle the native script (OTA phase C, C7.5c) (#21878)
* test(mobile): pin the terminal WebView document byte for byte The document is already pinned as a digest, which says whether the emitted bytes moved and nothing about where. C7.1 moves the hand-written script inside it into modules the web page can import and rebuilds the document from them, and the claim that has to hold through every one of those commits is that the native screen kept the document it had. A digest cannot be the instrument for that: it fails as two hexadecimal strings. So the document is also committed as itself. The fixture is generated by `scripts/build-terminal-document-fixture.mjs`, never pasted, and the test rebuilds the comparison through that script's own substitution rather than restating it, so a fixture written by one rule and read by another cannot agree with itself. The generated xterm engine is stored as two placeholders. It is already covered by the digest test, postinstall regenerates it from whatever xterm the lockfile holds, and inlining it would put 612 KiB of vendored bytes into the file whose job is to isolate hand-written changes. Two further cases keep that from becoming a hole: the placeholders must each appear exactly once and the engine must not appear at all, and the restored document must equal the real one. Regenerating the fixture is a review event. It is only correct when the emitted document was meant to change, and the diff in that commit is the evidence. Red-first: flipping one character inside a comment in `write-queue.ts` fails both identity cases with a one-line diff naming the comment, where the digest test reports a hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare two terminal documents as programs, not as bytes The C7.1 flip commit moves the document's 57 reassigned variables onto a scope object, because a variable assigned across ES modules is a syntax error, and every read and write of them gains a qualifier. The ruling asks that the review of that commit be a test rather than a 515-line read. This is that test's instrument. It cannot be a byte comparison. Once the script's source is modules, `oxfmt` owns its style, and the repository's style has no semicolons where the hand-written document has one on nearly every line. A byte diff would therefore be dominated by changes that are not the refactor, which is the opposite of what the reviewer needs. So the comparison is over tokens: semicolons are excluded for the same reason they moved, comments never reach the stream, and one difference is allowed — `name` becoming `<qualifier>.name`, three tokens for one — which it counts and reports. It is stricter than "it still runs": a reordered statement, a changed literal, a dropped operator, a renamed local and a qualifier under the wrong object name all diverge, each reported with the token index and both sides. Acorn carries `value` on its tokens but does not declare it, so the field is read through a narrowing check rather than asserted onto the declared type. Red-first, by mutation: dropping the qualifier-name check fails the case that names it; removing the leftover-token check fails the dropped- and added-statement cases; treating semicolons as significant fails the three cases that depend on ignoring them. The acceptance case runs on the real 2,758-line script rather than on a fixture, so the instrument is known to survive everything the document actually contains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count each normalisation the move makes, separately Measured while extracting the first group: the document's ES5 style is not a style this repository's own rules permit. `curly` braces 279 brace-less if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become `const`, `let` or a scope field. Those rewrites land before the qualifier is considered at all, so "the qualifier and nothing else" was never reachable once the source is a linted module. The comparison now allows exactly four classes and counts each on its own: a reference that gained the qualifier, a declaration that moved onto the scope object, a `var` that only changed keyword, a body that gained braces, and a catch clause that lost its binding. Separate counters rather than a total, because the flip commit pins each number and a total would let one class absorb another — which is the drift the pin exists to catch. The two `var` classes partition the 446, and the qualifier's 641 sites partition into references that kept their declaration and declarations that moved. Two ordering facts the cases pin. The catch rule is tried before the brace rule, or the inserted-brace rule eats the `{` that follows `catch` and the streams never resynchronise. A body braced at the very end leaves its closing brace after the baseline has run out, so trailing closes are absorbed after the walk rather than reported as a length difference. Everything outside the four classes still refuses with the token index and both sides: a changed literal, a dropped operator, a reordered pair, a renamed local, a qualifier under another object's name, a brace opened and never closed, and a brace closed where none was opened. Red-first, by mutation: disabling the catch rule, disabling the trailing-brace absorption, folding scope-field declarations into plain references, and not counting brace insertions each fail exactly the case that covers them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the mouse-report cell a module the page can import The first of the twelve groups the document already names. `*-injected.ts` has been splicing JS strings into the document for a while, and tests evaluate those strings, so the one-source-two-consumers shape is already there; what is missing is that a string cannot be imported by the web page, typechecked, or linted. This turns one of them into a module and adds the generator that puts it back into the document. The generator is a transform, not a bundle: a bundler orders its output by the dependency graph, and the document's order is part of what the equivalence test holds fixed. Imports are dropped rather than resolved, because inside the document every name is already in scope — that is what the single IIFE means — and `document-externals.ts` declares the names whose groups have not moved yet and emits nothing at all. esbuild prints an ESM module's exports as a trailing block, so that block is dropped whole rather than by its keyword; leaving the keyword behind would put a bare block statement in the document. Both sides of the comparison now go through that same printer before being read. Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as a difference in the program when it is a difference in who typed it, and each would need its own rule. A script that does not parse is reported as a refusal naming its side, not thrown. `let` is contextual outside strict mode, so acorn reports it as a name and not as a keyword; without that the var-to-let rewrite the linter performs would be refused on every reassigned local. The group's counts are pinned exactly: nine references gained the qualifier (`term` seven times, `panX` and `panY` once each), nine locals became `const` or `let`, thirteen one-statement `if` bodies gained braces, no declaration moved onto the scope object and no catch clause lost a binding. The document is untouched, so the byte pin from |
||
|
|
a08a4915b3 |
test(mobile): wait for the frame's script-src refusal before reading it (#21948)
securitypolicyviolation is dispatched as a queued task, so its order against the frame's load is not guaranteed. On the runner's Chrome the list held only the blocked background image when the arm read it, and the assertion failed on an entry that had not landed yet; this PR's own run passed by timing. The header-keeping arm now waits for its own evidence, bounded by the case's signal like every other wait here, and the assertion is unchanged. A frame that was never widened raises no such entry at all, so the wait is the arm's precondition rather than a convenience, and it ends in the rig's diagnosis rather than a bare hang. Red-first, isolated to the new wait: with nothing in the artifact for the policy to refuse, the case fails at its timeout and prints "the frame never reported a script-src refusal" with the arm, the browser and the frame's own readings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
8d42410e01 |
feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener Both shells cancelled every navigation off their own document in silence: iOS `decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's `shouldOverrideUrlLoading` dropped anything whose resolved path was not "/". Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping a link inside C7.10's sealed HTML-preview frame reaches the top frame as a navigation request, and the shell was the only thing that could act on it. A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and goes through the same `Linking.openURL` the `externalLink` notify already uses. The scheme list is not restated natively: the native side caps the string and says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens in the half that ships over the air. A subframe navigation is never offered, because that is the sealed preview loading itself. swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render the HTML preview in a sealed srcdoc frame on the page C7.6 gave the page the artifact's source, which is the native component's Source tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes back as an `<iframe sandbox srcdoc>` inside the page's own document. `srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`, while a `blob:` frame is refused by `frame-src` on both and refused a second time in WebKit by the `frame-ancestors 'none'` it inherits. Two independent fences seal it, and the render check measures each on its own: the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the inherited `script-src 'self'` refuses the artifact's inline script even when a control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'` govern its subresources, against a no-header control where the same three are fetched. `allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so a tapped link becomes one top-frame navigation the shell now opens externally, while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated navigation produce none. `lucideBarrelPlugin` is exported from the bundle builder so the check builds the toolbar's icons the way the page does rather than carrying a second shim. config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script fence and the token census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's sealed frame where the degradation was pinned The three HTML-preview cases in this file described the state ruling 26 retires: no toggle, no frame, the source only. They now pin the frame's shape through the test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither `allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source takes the frame away with it -- and the "never renders the html itself" case becomes "never puts it anywhere but the frame", counted rather than merely absent. What a browser does with that frame stays in the render check, which is the only thing that can answer it. The rich Markdown editor's half is unchanged: it is still the plain field, and item C is a later PR. Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the toggle renders one, and `lucide-react-native`, whose barrel imports a `LucideProvider` its own context module does not export and so does not load under vitest at all. 9 passed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a link-activated top-frame navigation, even to the document F1, blocking, with F5 and F6 folded in because they are the same decision and splitting them would mean three rewrites of one function. F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the embedder's base, so both named the shell's own document URL -- which both shells ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed preview reloaded the shell's page: bridge target cleared, load state restarted, page state gone. A navigation a human started is now never allowed, whatever it names; it is offered instead, and `cancelledShellNavigationTarget` drops `orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting its own path carries no gesture and is still allowed. F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a redirect is cancelled and never opened externally. F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a download>` was dead on iOS and opened on Android. The early return goes; a download is refused rather than allowed when nothing started it, and a gesture-started one reaches the opener on both platforms. The allow half and the offer half are now one function per platform (`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so they cannot drift. The gesture is the platform's own answer: `.linkActivated` on iOS, `request.hasGesture()` on Android. Native tests, both platforms: document URL + gesture refused and offered; document URL without gesture allowed; foreign + gesture cancelled and offered; foreign without gesture cancelled and silent; download both ways; subframe never offered. swiftc OK; control run with the gesture rule removed exits 133. Gradle MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0. Also corrected: the screen comment that claimed the document's own reloads reach the handler (they never do), and the prop doc, which now states the gesture rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count own-origin top-frame navigations, and drop the goto cap F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling render check uses the 30 s default, so under load the first WebKit cases redded on the navigation rather than on anything they assert. The cap goes; the per-action timeouts that needed to be short are already passed at their call sites. F1's page-side half: the rig now routes the page's own origin as well as the foreign one and counts main-frame navigations to each separately, with two cases pinning that `href="/"` and `href=""` each produce exactly one own-origin top-frame request. Playwright is not the shell, so what these state is the request the shell is handed; refusing it is the native tests' job and the docstring names which ones. The own-origin route is registered after the initial load, because it aborts main-frame navigations and the first `goto` is one. The foreign-tap and meta-refresh cases now also assert zero own-origin navigations, so a fix that merely moved the target would not pass. 16 passed, exit 0, no Errors line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for the preview frame's own load, never a clock CI read the child frame before its srcdoc committed: frameUrl came back '' and the control arm's script as not yet run. The frame list, the frame's URL and anything read inside it settle at their own moments, and a 900 ms wait reads whichever of them has happened -- on a loaded runner, none. Polls for a child frame at about:srcdoc with its load fired, bounded by the case's own timeout, and an override arm now resolves on the document its srcdoc assignment commits rather than on the assignment. Red-first: with a 2.5 s mount delay standing in for a loaded runner, the paint case failed on both engines before this and all 16 cases pass after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say whose violations the preview rig reads The list is the main frame's: securitypolicyviolation does not cross into a frame, so an empty one says the embedder raised none and says nothing about the artifact's own style, image or font. A listener inside the frame cannot be the fix -- the fence under test is that nothing in the artifact runs. So the comment now claims what the reading supports, and names where the frame's containment is actually measured: the pixel for its inline style, the counting server for its img-src and font-src. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce which side of the preview toggle is showing The Preview/Source pair carried a label each and nothing else, so which one was showing lived only in the active background -- invisible to a screen reader on both surfaces. Each button is now a tab carrying its selected state, inside a tablist, and the two files' toolbars stay character-identical so the page and the phone announce the same thing. Red-first: the new case renders both siblings and failed on both for the missing role before this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the WebView mock like the file's other hosts The anti-slop gate refuses a bare `object` parameter. Takes the same shape as the react-native mocks beside it, which pass it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): allow only the load the shell itself started The document URL was allowed whenever the host reported no gesture, so a navigation the shell never asked for could reload the page out from under the session. Measured against a real WKWebView off-device: a sandboxed subframe navigating the top frame to the document URL arrives as `.other` with no gesture at all, and Chromium's own docs allow hasGesture() to be false for a request a human started. Census first: nothing in the page navigates the top frame -- no location assignment, reload, replace, window.open or form -- the router moves by pushState and replaceState only, so the rule needs no gesture and no page cooperation. Both shells now raise a flag around their own load and drop it at commit, and allow a main-frame navigation only while it is up. Everything else naming the document is refused and never offered, since offering it would send the user out of the app. iOS carries the second discriminator the same probe measured: sourceFrame is the main frame for the shell's own load and the subframe for a subframe's top navigation, so a subframe can never take the allow path. Red-first: the Swift checks and the Kotlin tests were written first and failed to compile against the old signature. 9 Kotlin tests, 54 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): point the meta-refresh arm at the embedder's own URL The fixture pointed off-origin, so its own-origin assertion could not move whatever the frame did. The new arm refreshes to `/`, which resolves against the embedder's base, and pins zero top-frame requests on a counter the `href="/"` case proves reads 1 in the same rig. It also counts what the frame asks for itself, with a presence control that attributes the fence: with `allow-same-origin` and no policy the same fixture navigates the frame to the embedder's `/`, and with the policy dropped but the product's token kept it navigates nothing, so the opaque origin is what refuses it rather than the CSP. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read what an action produced, not what a clock allowed The 600 ms after every action is gone. An arm that expects a navigation now returns the moment the route handler records it, with a deadline only so a click that missed its target says so instead of spending the case's timeout. An arm that expects none waits for two painted frames inside the page and one 200 ms drain for the popup queue, which is a browser-process event with no in-page counterpart; the docstring says why that one is bounded. Measured and reported rather than claimed: with the new wait replaced by a no-op every arm still passes, because the reads that follow are each a round trip. It is insurance against the runner load that produced the frame-commit race, not a fix for a failure seen here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the settling branch as a ternary What oxlint's prefer-ternary asks for, and the changed-code gate with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the preview frame by its element, not its URL CI timed out on all seven preview cases on one engine: the poll waited for a child frame whose URL reads about:srcdoc, and that browser reports an empty URL for a srcdoc frame, so every case ran to its own timeout. The same difference had already shown as `expected '' to be 'about:srcdoc'`. The frame is now the element: waitForSelector('iframe') then contentFrame(), with readiness taken from the fixture's own marker inside it. Nothing compares a frame URL any more -- the paint case reads the element's srcdoc attribute and the absence of src instead, which is what "parsed inside the frame rather than fetched into it" actually means. The one arm whose artifact navigates the frame away says so rather than waiting for a marker that is not coming. Red-first: with the old poll keyed on a URL the browser never reports, both engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s mount delay still injected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a frame that never becomes ready say what it saw The runner's Chrome read the preview frame's URL as empty where three chromium builds here read about:srcdoc: bundled headless, the headless shell, and --headless=old, all 147. So the difference is not reproducible locally and the next CI run has to carry its own diagnosis. The marker wait is bounded well inside the case timeout, and on expiry it reports the frame's URL, the srcdoc attribute's length and the page's CSP violation list -- which separates a frame the policy refused from one that was merely slow, the two readings that look identical from a timeout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the containment arms the comment only claimed The comment said the fixture navigates nothing with the policy dropped and the product's token kept, but no arm ran it: the control dropped both fences at once. Both single-fence arms exist now, either of which would hold. Measured rather than assumed, and one of them is not what the comment said. The token alone: the navigation never starts, no request, no violation. The policy alone, with allow-same-origin granted: the navigation does start and frame-src refuses it, which the embedder reports as its own violation. The engines differ only in what is left in the frame -- chromium an error page, WebKit the artifact -- so neither is asserted; what is asserted is that the request never reaches the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a download that names the shell's own document The document branch skipped downloads, so `<a href="/" download>` fell through to the offer path carrying the shell's own URL. Harmless in practice, because the opener's scheme list drops it, but it contradicted the policy's own comment and the prop doc, and it left the one URL that must never be offered reaching the boundary. The branch now covers a download too: refused, from either frame, gesture or not, and never offered. A gesture-started download of anything else still reaches the opener. Red-first on both platforms: the Swift checks exited 133 and the Kotlin row failed against the old policy. 10 navigation tests, 55 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the own-load flag wherever a document ends The flag lived beside the load call and had to remember every ending separately, so iOS missed two: a prop update that fails before it loads, and a renderer that died. Both left it raised, and a navigation to the document URL during that window would have been allowed. It now lives in the load state machine, which every ending already runs through -- a commit, a failure, a dead renderer, a prop update, a reset -- on both platforms, so there is nothing left to remember. The view raises it and reads it, and drops it nowhere. The Android residual is stated in the policy rather than papered over: between loadUrl raising the flag and onPageStarted dropping it, a navigation to the document URL from inside the preview frame would be allowed, because that callback says nothing about which frame asked and no host discriminator exists. It needs a generation switch and a tap in that window; iOS closes the same gap with sourceFrame. Red-first: the new Swift row failed to compile and the Kotlin row with it. 12 load-state tests, 56 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spend the own-load flag on the allow, not on the commit The flag stayed raised from the load until didCommit, so a second main-frame action naming the document inside that window was allowed too and replaced the document. WebKit can decide a second action before the first one starts, so the commit is too late to be what spends it. The allow itself spends it now, before the decision goes back, and every ending still drops it for a load that is allowed and never commits. Red-first: the new check composes the machine with the policy -- the seam the flag and the rule meet at -- and failed to compile against the old machine. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop raising an own-load flag Android never consults WebViewClient's javadoc, verbatim: "This callback is not called for all page navigations. In particular, this is not called for navigations which the app initiated with loadUrl(): this callback would not serve a purpose in this case, because the app already knows about the navigation." So the flag guarded nothing on this platform and, while raised, was the one thing that could have let a competing request to the document URL through. The view passes isShellLoad = false always now, the machine drops the field it had no raiser for, and the policy comment carries the quote. Nothing reaching that callback is the shell's own load, so nothing naming the document is allowed there at all -- which also closes the generation-switch window the residual named, so that paragraph goes. No red to show: this is a removal, and the behaviour it leaves is the refusal the existing rows already pin. What a device proof must check is stated in the policy instead: a WebView that did route its own load here would have it refused and the load state would sit at loading. 55 tests in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): settle every arm, not only the ones that tap An arm with no action read its counters as soon as the frame's marker appeared, so a zero-delay meta refresh could dispatch after the reading. The arms that pin zero were the ones relying on it. Every arm settles now, and what it settles on is what it expects: the sealed refresh arms take the bounded no-navigation path, and the loose arm waits for a recorded navigation that is neither main-frame nor foreign -- its own frame's -- rather than the main-frame wait it would never satisfy. Red-first: with the settling removed and the refresh moved to 2 s, the loose arm reads 0 on both engines; with it back, 1 on both, the delay still in. A 0.4 s refresh passes either way, which is why the finding was invisible. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for what the artifact's script wrote, not for the element The two-fences control asserts the inline script ran, and the marker element it waited for exists from parse time, so the arm could read window.__ran before the script had touched it. Under a loaded runner that reads 0, which is CI's "expected +0 to be 1" on chromium. Readiness is now per-arm: 'script' waits for the script's own write, 'load' for the arm whose artifact navigates the frame away, 'artifact' for the rest. Red-first: with the inline script's write delayed 1.5 s, the old arm fails on both engines with that exact message and the new one passes, delay still in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): bound the rig's waits by the case timeout and nothing else Two inner deadlines, 20 s and 15 s, were racing the outer one they sit inside, so a slow runner could fail a case on a number this file picked rather than on the one the case declares. Both now run to vitest's own `ctx.signal`, which aborts when the case times out. On abort the rig prints its reading -- the frame's URL, the srcdoc length, the violation list, or the navigations it did record -- and lets the case fail as the timeout it is. Nothing is rethrown from that path: a rejection raised after vitest has given up on a case has nobody left to catch it, and an unhandled one fails a run whose every test passed. Red-first: with the marker selector pointed at an element that never appears and the case timeout cut to 8 s, the diagnostic prints and the case fails as `Test timed out in 8000ms` rather than hanging in silence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): ask a stuck preview frame everything it can still answer The old diagnostic said only that a frame never parsed, and its violation list was the top document's -- securitypolicyviolation does not cross frames, so it said nothing about what the frame itself refused. It now prints the browser version, the arm it came from, the iframe element's srcdoc length and sandbox, contentDocument.readyState and contentWindow.href (which answer for a same-origin arm and report `refused` for an opaque one, so the arm's own origin is in the log), and every Playwright frame with its url, name, readyState, body length, marker presence, window.__ran and its own violations. Per frame, because the page's init script installs the collector in every frame -- measured on both engines -- and CDP evaluates inside an opaque frame whose scripts are blocked. Two corrections that the local probes forced. The reading is sampled while waiting and printed from the last sample: read at the abort it lost its race with vitest's teardown and printed nothing at all. And two arms had never been given the case's signal, so their waits could not be bounded or diagnosed. The diagnosis moves to its own module because the test file is at its line limit, and because the bound and the reading it prints are one thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build a widened control frame instead of relaxing a live one A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context when it is created, and Chrome 152 keeps the original ones through a srcdoc reassignment while still parsing the new document -- so the control arms that widened the product's own frame stayed sealed on the runner, and CI read a script that never ran and a refresh that never navigated. Chromium 147 here honours the relaxation, which is why it passed locally for a year of runs. The override now clones the element, sets the sandbox on the clone, gives it the artifact and replaces the product's frame with it, so the widened flags are there from creation -- the way the product does it, since React sets the attribute before insertion and never after. The product's own arms are untouched: a null override still returns immediately. And the control can no longer pass for the wrong reason on any engine. The header-keeping arm now reads the violation raised inside the frame: a script-src refusal can only happen if the sandbox let the script start, so it separates "the policy held" from "the frame was never widened", which the old arm could not. The loose arm pins an empty list beside it, the sealed arm pins an empty one too, and those three readings are the whole fence story. The violations come from each frame's own collector, because the embedder never sees them. Two diagnostic repairs the local probes forced: the browser version is read once at open, since asking at the abort printed "browser unknown" in the CI log this exists for, and the reading is sampled immediately as well as every five seconds, since a wait that only prints "no reading was taken" says nothing. Red-first: with the widening disabled, both engines fail exactly as CI did -- 180 s timeouts on the script arm -- and the diagnostic names the arm, the version and the sandbox it actually had. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the toggle's selected state where a browser reads it CodeRabbit is right, and the browser says so: react-native-web's createDOMProps never reads accessibilityState, so on the page the tab pair emitted role="tab" and no aria-selected at all. The test renderer could not see it, because it reports the props the component was handed rather than the DOM they become. Both siblings carry aria-selected beside accessibilityState now -- the phone's screen reader takes the latter, the browser the former -- and the toolbars stay character-identical. Red-first, in a real browser on both engines: the rig now reads every [role="tab"] element's aria-selected before and after the tap, and it read null for both positions before this line existed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5e5d731870 |
test(config): assert the target route's chunk was never fetched, not that no chunk arrived after the tap (#21922)
* test(config): name the chunk the handoff check says is absent The three handed-off cases snapshotted the loaded scripts the moment the page returned and then asserted nothing new arrived after the click. The opener's own route chunk can still be in flight at that moment, so under full-suite load it lands afterwards, counts as new, and reds a case whose rule held: the notify was posted and the document had not moved. They assert what the comment always claimed instead -- the target route's own chunk was never fetched -- resolved from the build's route-to-chunk map, since the bundler hashes chunk names and there is nothing in a URL to recognise a route by. Being about one named chunk over the whole run rather than a delta, it cannot be raced by an unrelated arrival. An absence needs a presence precondition, so `beforeAll` fails if that route has no chunk; without it a typo would pass all three. Red-first control: with the coverage test in `route-handoff.web.ts` forced to keep the hop local, the chunk assertion alone fails and names the tasks chunk it fetched. Found by the C7.5b lane. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): judge the absent chunk on every status, not only 200 CodeRabbit: the three absence assertions filtered the 200-only list, so a request for the tasks chunk answered 404 or 500 left no trace in it and the assertion passed on a fetch that did happen -- the one shape where a page that tried to render the target reads as a page that never asked. They read `jsResponses` now, which carries every JavaScript response with its status, and a request for that chunk fails the case whatever came back. The 200-only list had no other reader, so it is gone rather than left as a second collection to pick the wrong one from. Re-proved on this form, with the coverage test forced to keep the hop local: the chunk assertion alone reds and now prints the status beside the path. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
37dbb6cf6e |
fix(windows): prune the unpatched conpty prebuild by header, not host arch (#20048)
* fix(windows): prune the unpatched conpty prebuild by header, not host arch `prunePackagedNodePty` deleted the published `prebuilds/win32-<arch>/conpty.node` only when `electronArch === process.arch`. That proxy stood in for "build/Release holds an addon this slice can load", and it is false for the arm64 slice cross-built on an x64 Windows host — a rebuild that DOES emit a correct arm64 addon. That slice shipped the unpatched prebuild. Nothing loads it today: `verifyPackagedConptyBreakawayMarker` resolves the addon the way node-pty's loader does, so the patched `build/Release` wins and the release passes correctly. But the loader swallows every require failure and falls through, so an AV quarantine or a missing dependency on `build/Release` hands the pane to that unpatched prebuild — the silent downgrade the gate exists to close, with the binary still sitting in the package. Read the PE `Machine` field instead of guessing, reusing `readPeMachine` from the verifier's `windows-pe-machine.cjs` so prune and verifier ask one question. A missing, truncated or non-PE `build/Release` reads as unloadable and keeps the prebuild, which is what the true cross-host case needs: packaging Windows from macOS leaves no Windows binary in `build/Release`, and removing the prebuild there would leave the package with no ConPTY at all. Mutation-proven: restoring the `electronArch === process.arch` guard fails exactly the two new rows in packaged-node-pty-prebuild-prune. * docs(windows): note the cross-arch conpty slice is real but not yet built |
||
|
|
4a3a32206d |
refactor(mobile): the terminal document is a function of its host (OTA phase C, C7.5b) (#21859)
* test(mobile): pin the terminal WebView document byte for byte The document is already pinned as a digest, which says whether the emitted bytes moved and nothing about where. C7.1 moves the hand-written script inside it into modules the web page can import and rebuilds the document from them, and the claim that has to hold through every one of those commits is that the native screen kept the document it had. A digest cannot be the instrument for that: it fails as two hexadecimal strings. So the document is also committed as itself. The fixture is generated by `scripts/build-terminal-document-fixture.mjs`, never pasted, and the test rebuilds the comparison through that script's own substitution rather than restating it, so a fixture written by one rule and read by another cannot agree with itself. The generated xterm engine is stored as two placeholders. It is already covered by the digest test, postinstall regenerates it from whatever xterm the lockfile holds, and inlining it would put 612 KiB of vendored bytes into the file whose job is to isolate hand-written changes. Two further cases keep that from becoming a hole: the placeholders must each appear exactly once and the engine must not appear at all, and the restored document must equal the real one. Regenerating the fixture is a review event. It is only correct when the emitted document was meant to change, and the diff in that commit is the evidence. Red-first: flipping one character inside a comment in `write-queue.ts` fails both identity cases with a one-line diff naming the comment, where the digest test reports a hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare two terminal documents as programs, not as bytes The C7.1 flip commit moves the document's 57 reassigned variables onto a scope object, because a variable assigned across ES modules is a syntax error, and every read and write of them gains a qualifier. The ruling asks that the review of that commit be a test rather than a 515-line read. This is that test's instrument. It cannot be a byte comparison. Once the script's source is modules, `oxfmt` owns its style, and the repository's style has no semicolons where the hand-written document has one on nearly every line. A byte diff would therefore be dominated by changes that are not the refactor, which is the opposite of what the reviewer needs. So the comparison is over tokens: semicolons are excluded for the same reason they moved, comments never reach the stream, and one difference is allowed — `name` becoming `<qualifier>.name`, three tokens for one — which it counts and reports. It is stricter than "it still runs": a reordered statement, a changed literal, a dropped operator, a renamed local and a qualifier under the wrong object name all diverge, each reported with the token index and both sides. Acorn carries `value` on its tokens but does not declare it, so the field is read through a narrowing check rather than asserted onto the declared type. Red-first, by mutation: dropping the qualifier-name check fails the case that names it; removing the leftover-token check fails the dropped- and added-statement cases; treating semicolons as significant fails the three cases that depend on ignoring them. The acceptance case runs on the real 2,758-line script rather than on a fixture, so the instrument is known to survive everything the document actually contains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count each normalisation the move makes, separately Measured while extracting the first group: the document's ES5 style is not a style this repository's own rules permit. `curly` braces 279 brace-less if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become `const`, `let` or a scope field. Those rewrites land before the qualifier is considered at all, so "the qualifier and nothing else" was never reachable once the source is a linted module. The comparison now allows exactly four classes and counts each on its own: a reference that gained the qualifier, a declaration that moved onto the scope object, a `var` that only changed keyword, a body that gained braces, and a catch clause that lost its binding. Separate counters rather than a total, because the flip commit pins each number and a total would let one class absorb another — which is the drift the pin exists to catch. The two `var` classes partition the 446, and the qualifier's 641 sites partition into references that kept their declaration and declarations that moved. Two ordering facts the cases pin. The catch rule is tried before the brace rule, or the inserted-brace rule eats the `{` that follows `catch` and the streams never resynchronise. A body braced at the very end leaves its closing brace after the baseline has run out, so trailing closes are absorbed after the walk rather than reported as a length difference. Everything outside the four classes still refuses with the token index and both sides: a changed literal, a dropped operator, a reordered pair, a renamed local, a qualifier under another object's name, a brace opened and never closed, and a brace closed where none was opened. Red-first, by mutation: disabling the catch rule, disabling the trailing-brace absorption, folding scope-field declarations into plain references, and not counting brace insertions each fail exactly the case that covers them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the mouse-report cell a module the page can import The first of the twelve groups the document already names. `*-injected.ts` has been splicing JS strings into the document for a while, and tests evaluate those strings, so the one-source-two-consumers shape is already there; what is missing is that a string cannot be imported by the web page, typechecked, or linted. This turns one of them into a module and adds the generator that puts it back into the document. The generator is a transform, not a bundle: a bundler orders its output by the dependency graph, and the document's order is part of what the equivalence test holds fixed. Imports are dropped rather than resolved, because inside the document every name is already in scope — that is what the single IIFE means — and `document-externals.ts` declares the names whose groups have not moved yet and emits nothing at all. esbuild prints an ESM module's exports as a trailing block, so that block is dropped whole rather than by its keyword; leaving the keyword behind would put a bare block statement in the document. Both sides of the comparison now go through that same printer before being read. Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as a difference in the program when it is a difference in who typed it, and each would need its own rule. A script that does not parse is reported as a refusal naming its side, not thrown. `let` is contextual outside strict mode, so acorn reports it as a name and not as a keyword; without that the var-to-let rewrite the linter performs would be refused on every reassigned local. The group's counts are pinned exactly: nine references gained the qualifier (`term` seven times, `panX` and `panY` once each), nine locals became `const` or `let`, thirteen one-statement `if` bodies gained braces, no declaration moved onto the scope object and no catch clause lost a binding. The document is untouched, so the byte pin from |
||
|
|
5d13a70ea3 |
fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9) The page decides an in-page hop from `init.pageRoutes`, which says which patterns this shell would render and nothing about what each one costs. So a push kept local on the strength of the pattern alone runs the target under the opener's grants — which is how the tasks page is reached from the wide-layout sidebar without `native.clipboard.write`, and why its copy actions refuse silently. `init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs, from the manifest the shell already holds. Optional in both directions: an older shell omits it and an older page ignores it, and a page that receives none keeps today's rule. No new frame kind, no cap change, no protocol bump. The grammar is the manifest's, imported rather than restated (`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the bundle could not have declared cannot reach the page through this field either. The host validates the pairs before it builds the frame and refuses the session when they fail, for the reason it already refuses a malformed route: an `init` the page would reject whole is worse than no session at all. Two files were at their line ceiling and are split rather than bumped. The pairs schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope and the host, so it belonged in one place anyway. In the session reducer the three sites that each spelled out "patterns, their grants, this route's grants" become one `routeViewOf`; that is a net reduction and removes the fourth spelling before it is written. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a hop local only when the session's grants cover it The rule the page was using is "the shell would render this pattern", and that is not the question. Grants are resolved once, from the route the shell opened, so a push kept local runs the target under the opener's list. On a wide layout the sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this seam, so from the worktree list, agent history or the files pages the tasks page ran without `native.clipboard.write` and its copy actions refused with nothing on screen to say why. `servedHere` now means served here *and* covered: the target's declared grants must be a subset of this session's. An uncovered page route is handed to the shell exactly like a non-page route, and the shell opens it as its own session with its own grants — which is the mechanism that already exists, rather than a new one. Three answers, not two, because an absent field is not an empty one. A shell that sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older shell has to keep working. A target the shell lists but names no entry for is *not* covered — the page cannot justify that hop, so it hands it over rather than guessing in the direction that loses grants. This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is covered by this rule and stays local, and the rule scales to the sidebar, which reaches every route and which no pairwise list can keep up with. Red first on the two cases only the new rule answers; the other four are the regression guards and passed before and after. Two whole-session assertions gained `pageRouteGrants: null`, which is what the reader now returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop in a browser, under the session's own grants The unit tests pin the decision; only a browser shows the control exists, is reachable at the viewport where the sidebar renders, and that the document does not move when the hop is handed over. Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted when a caller names none, because an absent field is not an empty one and the page reads the difference). - Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one `navigate` notify, the document stays on the worktree list, and **no new chunk is fetched** — which is what says the page did not quietly render tasks under the wrong grants. - Wide, same tap with the grant added: no notify, the document moves to `/tasks`. Without this the first case would pass on a page that simply never navigates. - Wide, shell sending no pairs at all: the old behaviour, local. An older shell must not start handing every hop over on a field nobody sent. - Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the sidebar only on a wide layout, and only that header branch labels its Accounts and Tasks controls; the narrow header's are unlabelled pressables. So the hop does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is the honest assertion. That unlabelled narrow header is a real accessibility gap and is not this lane's to fix. Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the in-page hops a session's grants cannot cover The rule landed in the commit before this one decides each hop; this says which hops those are, so a route's grants growing — or a new push between two page routes — shows up here rather than as a verb that silently refuses on a device. Openers are every page route, not the one that happens to push. On a wide layout `app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and its header pushes tasks, which is exactly why a pairwise pin is the wrong shape: the sidebar reaches everything, so the census has to be the cross product of what the manifest declares against what the source actually builds. Targets come from the hrefs the app builds, read out of `mobile/src` and `mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned and a hop someone adds is. A presence case asserts the sidebar's tasks push is among them, because a census that stopped finding hops would go quietly green. Two hops are pinned as handed off today, both into tasks, which is the only route declaring more than `navigate` and `storage`. A third case asserts the other half of the rule on the manifest: a target asking for no more than its opener stays in the document. Checked that it discriminates rather than assuming: widening the worktree list's grants to cover tasks fails the pin, and restoring them passes it. **No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has three routes and no `files` entry, so there is nothing to remove. When C3.1 lands, its pin is this census's to subsume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an unused import from the hop census `statSync` was imported and never used; `oxlint` fails it. My error: I committed the census on a green test run without waiting for lint, the same order mistake I made earlier in this lane. Fixed forward rather than amended, because the lane forbids rewriting a commit that exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold C3.1's pairwise grant pin into the hop census C3.1 landed while this branch was open, and it brought the case this lane generalises: the explorer pushes to its own preview, that push stays in the document, so the preview runs under the explorer's grants. Its pin asserted that one pair by name. The census now covers it as a consequence rather than a rule. With the files routes in the manifest the cross product finds six more hops the session cannot cover — the sidebar into files from the worktree list and from agent history, and both files routes into tasks — and it does **not** find explorer → preview, because the preview declares no more than the explorer. That absence is the pairwise pin, derived. So the pairwise block is deleted, with its import. The rest of that file stays: its external-link seam checks and its clipboard-absence control are about what the files closure contains, which this census says nothing about. Checked the extended census still discriminates: granting the explorer `native.clipboard.write` fails the pin, restoring it passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop from a files route, not only the worktree list The defect is not "the worktree list pushes tasks". On a wide layout the sidebar renders beside every `/h` route, so the same hop exists from the files explorer, whose session carries `externalLink` but not `native.clipboard.write`. One opener proving the rule would have left the general case to inference, which is the inference C3.1's pairwise pin already made once. Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's Tasks control posts exactly one `navigate` notify, the document stays on the files route, and no new chunk is fetched. The harness helper now takes the route and the text to wait for, so a case can open on something other than the worktree list without a second copy of it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the render helper wait on the text its caller named The `awaitText` parameter I added in the commit before this one was never wired into the wait, so it was dead and `oxlint` failed it. The case still passed, because the files route renders the host name in its sidebar and that is what the helper was still waiting on — which is exactly the kind of accident a dead parameter hides. Third time in this lane I have committed on a green test run before lint finished. Fixed forward, not amended. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry route grants through the download path `onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so every first install and every OTA update reached `ready` with the default or the previous generation's pairs. The page then read each target as listed-with-no- entry and handed off every in-page hop. `routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls, to keep the reducer under its line cap without a bump; its stale neighbouring comment, which described a filter that moved into it, goes. Red first: the cold-cache and generation-change cases failed, the cached-hit case already passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive census targets from navigation call sites The reachability filter was inert. Harvesting every `/h/${…}` template caught the five screens that declare their own mount pathname, two `pathname ===` comparisons and the route template types, so every declared route was reachable through its own mount: the pinned table was the all-pairs one, eight hops with the filter and eight without. Targets now come from the arguments of `router`/`navigation` `push`, `replace` and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are excluded by construction because they are not navigation arguments. Two real hops are not written as a literal, so a local binding or a call is followed one step to the function that returns the pathname: the files explorer is pushed as `{ pathname: descriptor.pathname }` and the preview as `push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read is returned rather than dropped. Derived patterns go from 11 to 10; the pinned table stays at eight because all five page routes are genuinely pushed to. What changes is that the filter now discriminates: deleting the header's two tasks pushes reds the presence case and drops the four `-> tasks` rows from the pin, where the old derivation stayed green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the pathname. A push added at a real call site appears in the set. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore the preview-declares-something guard The pairwise pin this case replaced asserted the preview declares at least one grant before asserting the explorer covers them all; without it two empty lists satisfy the subset check and a route that lost its grants passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the route list under the handoff rule Two passages described the world before this PR: the explorer's note said the census pins its pair with the preview, and a closing paragraph left the sidebar's tasks hop open for a later PR. This is that PR. Covering the preview now buys the in-document hop rather than making it correct, an uncovered target is handed to the shell and reopened under its own grants, and the census reads the explorer to preview relation off this list rather than pinning it by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the manifest's tasks grants in both fixtures CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`, `storage`, `native.clipboard.write` while the manifest also declares `externalLink`, so no covered-session case ever required it. Both now mirror the manifest's four, and the covered sessions hold them. That alone does not make an `externalLink`-blind rule fail, since those sessions hold every grant either way, so the unit suite gains the case that does: a session holding the clipboard but not `externalLink` must still hand the hop off. Mutating the rule to treat `externalLink` as always held reds that one case and no other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a stalled hop name its own cause Both waits for the hop to land read as a bare 30 s timeout when it does not. The CI failure that sent this file back was a `TypeError` inside React Navigation that blanked the document, and it was invisible here because the error assertions run after a wait that never returns. The wait now throws with the page's own account: the pathname it stayed on, the collected page and console errors, the `navigate` notifies posted, the first 300 characters of the body, and every `.js` response since the click with its status. The response listener records every script answer rather than only the 200s, so a chunk the navigation waits on can be seen failing; the 200-only list the no-new-chunk assertions read is unchanged, as is everything the five cases assert. Kept in this file because no other render file waits on the pathname moving. Proved by mutating the rule to hand every hop off: the covered case fails naming the pathname it stayed on, an empty error list, the notify it posted and no scripts since the click — which is the handoff signature, distinct from the crash signature CI saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): aim the narrow hop at the control C2.10 named The narrow case asserted the absence of a labelled Tasks control, which was true only because the narrow toolbar carried no accessibility props. C2.10 gave it the wide sibling's role and label, so the assertion was red on the merge and, worse, the rule this file is about went unproven on the branch the phone actually presses. It taps that control now: at 390 px there is exactly one, and the tap posts exactly one navigate notify for the tasks route while the document stays on the worktree list and fetches no new chunk. Red first against the merged header (count 1, expected 0); with the session given native.clipboard.write the hop goes local and the case reds, which is what says the assertions discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the handoff predicate's contradicted one-liner The pre-C2.9 summary said the answer is whether this document renders the target, which is exactly the claim the block comment below it replaced: the predicate now also requires the target's grants to be covered. Two doc comments on one declaration, the first of them wrong. Comment only; the 35 handoff cases are unchanged and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert which field a route refusal blames The host builds `pageRouteGrants: <issue>` so a refusal says which of the two checked inputs failed, and nothing read it: the case counted refusals, so a host that reported the route's own verdict for a malformed pair would have stayed green while sending whoever reads the refusal to a pathname that was never the problem. The case pins the prefix, a non-empty issue behind it, and that the diagnostic and the callback carry the same string. The control is an opener that fails the other way: a malformed route reports its own issue and does not take this prefix, without which the pin would hold on any reason at all. Red first with the field branch dropped from the reason: the prefix assertion fails and the control stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop exporting the route filter the reducer stopped calling `implementedPageRouteEntries` and `implementedPageRoutes` were the reducer's two ways in before it moved to `routeViewOf`. The entries form had no caller anywhere afterwards and the patterns form had only this test, so the module's public surface advertised two functions no product code reaches. Both are module-local now; the surface is `matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`, `routeViewOf` and the grant list. The test reads the same list through `routeViewOf(...).pageRoutes`, which is the reducer's own view of it, so no assertion changed and no export is kept for a test. Red first: with both un-exported and the test untouched, seven cases fail with `implementedPageRoutes is not a function`; routed through the view all nineteen pass. Still discriminating, as a control: with the grant filter dropped from the entries helper, four of them fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): route the merged haptics cases through the policy view PR E's two haptics cases arrived with the merge calling `implementedPageRoutes`, which this branch had already made module-local, so the merged file was red with `implementedPageRoutes is not defined` on both of them. They read the same list through `pageRoutesOf`, the view the rest of the file already uses, so neither assertion changes. PR E's paragraph named that function for the filter it describes; the filter now sits in the entries helper the view is built on, so the sentence says that instead of naming a function the reader cannot see. Red: the two cases above on the merge. Green: all 21, PR E's two included. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the haptics token in every handoff fixture PR E put `haptics` on all five manifest routes, and these fixtures still carried the pre-E grant lists: tasks with four grants where the manifest now declares five. A fixture that is short the same token on both sides of the subset check agrees with the rule by accident, and would have gone on agreeing after the token stopped being universal. The pairs mirror the manifest now, and each session carries what its opener route would actually be granted, since the host narrows a route's declared grants to what the shell implements and the shell implements the token. Red first, with the token added to the pairs alone: the two covered-hop cases flip to handed-off, `stays in this document when the session already covers the target` and `keeps the hop in the document when the session covers tasks`. Green once the sessions carry it, 35 and 5. The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself. Measured there, all 5 routes declare the token and it is the missing grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the rule still reads only `pageRouteGrants`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count C2.9's two bridge modules in the session route closure #21908 recorded this pin at 4,324 for the haptics notify module. C2.9 adds two more that the same closure reaches: the page-route-grants schema and the manifest contract whose grant grammar it imports rather than restates, both pulled in by `bridge-envelope.ts`, which the page reads to parse `init`. Named in the docstring beside #21908's sentence rather than folded into its number, because the three modules arrived from two PRs and a single count with one reason invites the next author to assume the rest. Red first against 4,324: expected 4,326. Measured on this head, not inferred -- a control worktree at pristine main gives 4,324, so the two are this branch's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |