From c22c442fdb1cecee29d2146bd4da46263a015fdf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:07:02 -0400 Subject: [PATCH] feat(mobile): answer native verbs on the shell, clipboard first (OTA phase C, C2.4) (#21623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): declare the native verb table and advertise it (OTA phase C, C2.4) The contract half of the shell-answered request seam: the `native.` prefix, a typed table with params and result schemas per verb, and the two clipboard verbs. `MOBILE_WEB_SHELL_GRANTS` spreads the table's own name tuple rather than restating it, so a verb cannot be advertised without a row and a row cannot exist unadvertised — the table is `Record`, so a missing row does not compile, and the suite holds the other direction. Verb names go in the flat grant list on purpose: a route may declare one, and a shell that lacks it keeps that route native rather than walling it. The mime shape admits `image` because a later build will serve one; this one refuses it, and the reason will say out of scope rather than unsupported, since `expo-clipboard` implements the image calls. No frame kind is added and no protocol version moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): answer native verbs on the shell and fence them from the desktop (OTA phase C, C2.4) The host half of the seam. `forward()` is the one place a request reaches the client, so the `native.` check sits there and nothing about ids, caps, settlement or cancel moves: a native request takes a pending slot and settles on the same frames as a forwarded one. `readBridgeNativeVerbCall` is the whole decision, separate from the host so the `ungranted` arm can be exercised at all — every page is offered every verb this build implements, so through a real host that arm is unreachable today and is the point of the check once a grant is per-route. Refusals carry `native_verb_refused`, which the desktop's vocabulary does not contain: an unlisted method comes back from `MOBILE_RPC_METHOD_ALLOWLIST` as `forbidden`, so reusing that would make a leaked fence read as an ordinary scope refusal. Every case in the host suite reads `client.requests` for the same reason. `_meta` is omitted from host-authored replies per the ruling, which required making it optional on `RpcSuccess`/`RpcFailure`: the type required a field the wire never has. `isRpcResponse` does not read it, `runtime-rpc-envelope` already makes it optional on a failure, and nothing in this app reads it — every occurrence is a fixture writing one. Zero other type errors resulted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the harness verb-type import and drop an unused one Two leftovers from threading the native reply type through and then removing it: the host harness lost its `BridgeNativeVerb` import, and the request module kept a type import nothing uses. `tsc` and oxlint both failed on the previous commit; this is the follow-up rather than an amend. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the native verb suites and drop the dead reply type Three leftovers the ratchet caught, none visible to `tsc -p tsconfig.json`, which excludes test files: - the fence suite read `frame.payload` off the whole `reply` union, and a chunked reply has no `payload`; it narrows on the field now - the bridge hook's own suite builds its caller options inline and had no `serveNativeVerb` - `BridgeHostAuthoredReply` became unused once `_meta` was optional, and an exported type nothing reads is the pattern round 2 of C2.3 flagged; the statement it carried already lives in the verb table's header Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page a typed surface for the native verbs (OTA phase C, C2.4) `useNativeVerbs` is the page's side, typed from the same table the host serves, so a verb cannot be called with params the shell will refuse. Each call goes out as an ordinary `request` and settles on the ordinary frames; the method name is the whole difference. A verb the shell did not grant is refused before a frame is sent, because a rejection after a round trip and one that never left look identical to an `await` and only the first costs an in-flight slot — `granted` is exposed so a caller can pick its own fallback instead. Results are parsed rather than trusted: the shell is a different build than the page, and a result shape that moved should fail at the seam rather than halfway through a screen reading a field that is not there. No call site uses it yet; the two `Clipboard.setStringAsync` sites are the consumer PR's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send native verbs from the module that owns the request port (OTA phase C, C2.4) `use-native-verbs.ts` called `client.sendRequest` directly, which the unvalidated-request-port boundary refuses: new code must send through an `RpcOperation`, and nothing may be added to the inventory. An `RpcOperation` is not available to this seam. Its `method` is typed `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD` from the desktop's generated params catalog. Putting `native.clipboard.read` there would declare that the desktop serves a method the whole fence exists to keep off it. So the send moves into `bridge-rpc-client.ts`, already listed as an owner of the port — a module that implements the port rather than a call site picking its own method and acceptance. `callNativeVerb` rides the same frame, id space and in-flight cap as any request, and the page surface stays a thin typed wrapper that reaches no raw port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fence native methods on subscribe, not only on request (OTA phase C, C2.4) The fence sat in `forward()`, which is the one place a *request* reaches the client. A `subscribe` reaches the same client by another door: a frame naming `native.clipboard.read` opened a real stream on the desktop, and because `client.requests` stayed empty the whole suite read as green over it. Refused in `handleSubscribe` before the id is claimed, under the same `native_verb_refused` code, so nothing about the frame reaches the desktop or occupies a slot. Cancel needs no arm of its own: it can only settle an id that was admitted, and none is. The oracle is widened with it. Every case now reads the client's streams as well as its requests, because the old one could not see this at all — an absence that only ever looked at half the boundary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a native verb's answer to the result it declares (OTA phase C, C2.4) The table names a result schema per verb and the host never applied it, so a handler could answer `{ nonsense: 1 }` and the page's own parse would be the first to notice — halfway through a screen, not at the seam. Validated on the host and refused by name on a mismatch, which is what makes the table's claim true on the side that serves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the native verb member from being a raw port (OTA phase C, C2.4) `callNativeVerb(verb: string, params: unknown)` took any method, so `callNativeVerb('worktree.list', …)` reached the desktop through the real pair — a raw request port in the one module allowed to hold one, and invisible to the inventory, whose scan counts `.sendRequest` shapes and not a bare call inside the owner. The parameter is typed `BridgeNativeVerb` now, which is the fence for every caller the compiler can see, and the prefix is checked at runtime for one that reached the member through a widened type. The compile-time half is pinned by a `@ts-expect-error` the tests-typecheck ratchet holds: widening the parameter back makes that directive unused and fails there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal one typed error at the page (OTA phase C, C2.4) Only the seam's own refusal carried `native_verb_refused`. A handler that declined and a reply too large to send arrived as other categories with no code at all, and the hook rethrew a bare `Error(message)` — so a caller telling an out-of-scope mime from an unsendable clipboard had to read message text, and those want different handling. Three changes, one shape. The host re-raises a handler's failure under the seam's code, keeping the handler's message because that is what says why. `BridgeReplyUndeliverableError` carries its frame refusal as a code, so `reply-too-large` survives to the page. The hook throws `NativeVerbError` with a `reason` read off the code `reconstructBridgeError` already copies onto the rejection, plus `ungranted` for the arm this side decides. Removes the unreachable `ok: false` branch from the hook with it. The narrowing it was doing moves into the client member, which now promises a success or a rejection and nothing else. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): test the in-flight cap and cancel, not the duplicate-id branch (OTA phase C, C2.4) The case named for the cap sent the same id twice, so what it exercised was the already-in-flight check. It never held a second slot and would have passed against a seam that took none. It now fills the cap with distinct ids against a handler that never settles, and asserts the one over it is refused with the cap's own message. A cancel case goes with it: a native request cancelled before its handler settles posts nothing afterwards, the way a forwarded one does not answer an exchange the page has moved on from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native verb member's type with a directive, not a cast The case proving a desktop method cannot go through `callNativeVerb` reached the runtime guard with `as never`, which the casting gate refuses — and a cast is the wrong tool anyway: it asserts past the very type the case exists to pin. `@ts-expect-error` instead, which the tests-typecheck ratchet holds: widening the parameter back to `string` makes the directive unused and fails there. The call still runs, so the runtime guard is exercised too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): parse the shell's error code instead of reaching for it (OTA phase C, C2.4) The anti-slop audit refuses `Reflect.get`: dynamic input is parsed into a named shape before it is read. `code` is not a property of `Error` — it is whatever `reconstructBridgeError` copied onto the rejection from the capture — so a schema is the honest reader here, and it says what this takes without asserting the rest away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the id collision before the native fence on subscribe (OTA phase C, C2.4) The fence ran before the already-in-flight check, so a `subscribe` naming a `native.` method under a live request's id settled that request with the fence's message. The page lost the request either way — the collision class predates this PR — but it was told the wrong cause, which is the difference between a bug it can see and one it cannot. Collision first. The fence still runs before any slot is taken, so nothing about the frame reaches the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse a native verb result once, inside the catch (OTA phase C, C2.4) The result was parsed twice: by the send path against the table's schema, and again at each caller against the concrete one. The second parse was dead, and it sat outside `call`'s catch, so a shell answering a shape the page did not expect would have escaped as a bare `ZodError` — the one shape this surface promises not to throw. `call` takes the verb's result schema and parses once, inside the catch, so every failure leaves as a `NativeVerbError`. The params parse at the callers goes with it; the host validates params and the page builds them typed. Also moves the comment block documenting `onExternalLink` back above it, which `serveNativeVerb` had landed in front of. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal its own code, and keep handler words on the device (OTA phase C, C2.4) Two findings that had to land together. Six faults all arrived as `native_verb_refused` and differed only in message text, which the hook's own comment said nobody may switch on. And a handler's message crossed verbatim: a clipboard read that failed after reading is free to put what it read in its error, and the error frame is the only path out of this seam that is not a declared result. So each fault gets a code — unknown verb, ungranted, bad params, wrong result, out of scope, handler failure, native-on-subscribe — and the three paths that reached the page uncoded get one too: the in-flight cap, a non-native method through a widened member, and host disposal. `reason` is now drawn from a declared list with no `unknown` arm, asserted at the hook. A handler's code crosses and its message does not; the shell logs the real one. The out-of-scope mime stays distinguishable because the code carries it, not the text. `bridge-host.ts` crossed the line cap with this, so the serving half moves to `bridge-host-native-verbs.ts` — read the call, serve it, hold the answer to what the verb declares — leaving the host the frames around it. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse unknown verb params, and floor an unknown code (OTA phase C, C2.4) Two the bots caught, both about a shape one side does not know. `z.object` strips unknown keys, so `{ mime, value, unexpected }` dispatched as if the extra key had not been sent — and the page and the shell are separate builds, so a param the shell silently ignores is the shape of a verb that changed underneath a page. `z.strictObject` on the verb params and results. And the page passed any code through as `reason`, while its own doc and `NATIVE_VERB_REASONS` promised a closed list; a shell newer than the page would have fallen off the end of a caller's switch. Unrecognised codes floor to `unreported`, `reason` is typed to the list, and the doc says what the list actually is rather than the single code the per-arm ones replaced. The flooring is tested by delivering the frame such a shell would send: this build's host normalises an unknown code before it leaves, so the pair cannot produce one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve no request before the page has asked for a session (OTA phase C, C2.4) `serving` starts true so a page's first frames are not refused for arriving in the same native batch as its `ready`, but nothing checked whether an `init` had ever been sent. So a request from a document this host had told no caps, no grants and no route was forwarded to the desktop, or served as a native verb, while the notify path had refused exactly that since C0. Gated on `initSent`, under the protocol's own `before-ready` name. Streams are left alone: the finding names requests, and gating `subscribe` too is a wider change than it asked for — worth its own decision, since the same hole is there. Fourteen host cases and five hook cases were relying on this: they open a request without ever asking for a session, which no real page does. They take a `ready` now, through a harness option, and the counts that read what the host posted account for the `init` a session opens with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): grant a page session what its route declared, not what the app can do (OTA phase C, C2.4) `init.grants.native` handed every session the shell's whole capability set, so a route declaring only `navigate` and `storage` was granted `native.clipboard.read` as well. That was harmless while every grant was a navigation or a write the page could make anyway. It stopped being harmless the moment a verb reads something back, which is this PR. The session is now granted the intersection of what this shell implements and what the mounted route declared in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own `fault`. One list: `init` issues it and every grant check — notify and native verb — reads the same one, so what a page is told it may do and what it will be served cannot drift. `MOBILE_WEB_SHELL_GRANTS` and `implementsGrant` are unchanged; the shell's capability set is still the ceiling a route's list is drawn from. User-mediated authorization is not attempted here and goes to C2.7 as an open question. `use-mobile-web-shell-session.ts` crossed the line cap with the extra field, so the three effect workers that touch the network and the disk move to `mobile-web-shell-session-effects.ts`, leaving the hook its reducer and callbacks. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): key the pre-handshake refusal on the session, and apply it to streams (OTA phase C, C2.4) Two rulings, one mechanism. The gate keyed on the host instance, and a host is rebuilt whenever the client under it changes. The page does not know: the session id is the same, so it neither re-handshakes nor hears that the shell was replaced. So a live page's next request was refused, and would have been until reload — a regression, not a safety gain, and not covered by the in-flight settling as delivery-unknown. The host now inherits whether its session already handshook, which the hook records when the page first asks. And the rule is about the session rather than the frame kind, so `subscribe` is gated with `request`: a stream opened before the handshake was the same hole. Fourteen stream cases were exercising a state the protocol forbids — they subscribe without ever asking for a session, which no page does. Every one is about caps, backpressure windows, acks, cancel, idempotency or arity; none was testing anything through the hole itself. They complete the handshake now, and the counts that read what the host posted account for the `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge a cached fallback by its own routes and grants (OTA phase C, C2.4) The newer manifest is read before the download is attempted, so its `pageRoutes` and `routeGrants` are already on the session when the download fails. Opening the cached generation then mounted an older page under a newer bundle's grants: a cached route that never declared the clipboard would have been granted it by a manifest it is not running. The fallback now derives both from `cached.routes`, and applies that generation's own render eligibility before mounting it — a route only the newer bundle claims is not a route the cached page can serve. This is the Phase D "grants across generations" item arriving early. Only the grant side is fixed here; persisting a generation's grants with the generation itself stays Phase D's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): remount the shell on a route change, so its bridge cannot outlive it (OTA phase C, C2.4) A host captures the grants its session was opened with, and the agent-history route renders `MobileWebShellScreen` with a pathname derived from `worktreeId` and no key. So changing worktree updated the screen in place: the old bridge stayed mounted and kept authorising frames under the grants of the route the page had already left. Keyed on the route now, which makes the change a remount — the old bridge is disposed in the commit, before it can read another frame, and the new session starts with no grants until its own `init`. The worktree-list and embedded- browser routes are keyed on the host id for the same reason; the hazard is the same whenever a dynamic segment moves under a mounted shell. The probe that catches this uses an empty dependency array on purpose: keyed on the pathname it re-fires on a prop update and reads exactly like a remount, which is the one thing it exists to tell apart. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): let a manifest declare a native verb as a route grant (OTA phase C, C2.4) `GRANT_NAME_PATTERN` was dotless, and the contract's own pin asserted a dotted grant is refused. So no manifest the desktop can produce could declare `native.clipboard.write` — and once grants are scoped per route, a verb no route can name is a verb no route is ever granted. Every native verb was unreachable for every route. The grammar now admits the verb shape the table names: `native.` followed by at least two lowercase dotted segments, which is `native..`. A plain name wearing a dot is still refused, `native.navigate` included, so the pin keeps its meaning. Wire compatibility, checked rather than assumed: widening what a manifest field may contain is a new optional value reaching readers that shipped before it, and the phone's reader already tolerates one. Its route schema bounds a grant's length and nothing else, deliberately — an unknown name is not a parse failure that would refuse the whole bundle, it is a grant this build does not implement, so `implementsGrant` drops it and the route stays native. Both halves are now tested: an unknown verb leaves its route native and grants nothing, and a known one reaches `init.grants.native`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the session reducer suite by concern before main pushes it over the cap Merged with main the reducer suite reaches 805 lines against a cap of 800 — neither side alone crosses it, which is the case the lane rules warn about. Split at a concern boundary rather than raised: the grant-facing cases (the cached fallback's own routes, and a manifest verb reaching the session grants) move to `mobile-web-shell-session-grants.test.ts`, and the fixtures both suites drive the reducer with move to a shared module beside them, the way the bridge host suites already share a harness. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../h/[hostId]/agent-history/[worktreeId].tsx | 8 +- mobile/app/h/[hostId]/index.tsx | 3 + mobile/app/h/[hostId]/web.tsx | 3 + .../MobileWebShellScreen.test.tsx | 9 + .../mobile-web-shell/MobileWebShellScreen.tsx | 15 +- .../mobile-web-shell/bridge-host-contract.ts | 26 ++ .../mobile-web-shell/bridge-host-errors.ts | 69 ++++ .../mobile-web-shell/bridge-host-init.test.ts | 9 +- .../bridge-host-native-verbs.test.ts | 329 ++++++++++++++++++ .../bridge-host-native-verbs.ts | 72 ++++ .../bridge-host-notifications.test.ts | 4 +- .../mobile-web-shell/bridge-host-requests.ts | 12 +- .../bridge-host-test-harness.ts | 41 +++ .../src/mobile-web-shell/bridge-host.test.ts | 73 ++-- mobile/src/mobile-web-shell/bridge-host.ts | 49 ++- .../bridge/bridge-client-errors.ts | 12 + .../bridge/bridge-init-frame.ts | 9 +- .../bridge/bridge-native-verbs.test.ts | 95 +++++ .../bridge/bridge-native-verbs.ts | 132 +++++++ .../bridge/bridge-port-pair-test-harness.ts | 16 + .../bridge/bridge-rpc-client.ts | 30 +- .../bridge/use-native-verbs.test.tsx | 262 ++++++++++++++ .../bridge/use-native-verbs.ts | 151 ++++++++ ...ile-web-shell-agent-history-route.test.tsx | 67 +++- .../mobile-web-shell-session-contract.ts | 3 + .../mobile-web-shell-session-effects.ts | 138 ++++++++ .../mobile-web-shell-session-grants.test.ts | 60 ++++ .../mobile-web-shell-session-test-fixtures.ts | 127 +++++++ .../mobile-web-shell-session.test.ts | 132 +------ .../mobile-web-shell-session.ts | 26 +- .../page-route-policy.test.ts | 84 ++++- .../src/mobile-web-shell/page-route-policy.ts | 30 +- .../use-mobile-web-shell-bridge.test.ts | 37 +- .../use-mobile-web-shell-bridge.ts | 17 + .../use-mobile-web-shell-session.ts | 129 +------ mobile/src/platform/native-clipboard.test.ts | 58 +++ mobile/src/platform/native-clipboard.ts | 49 +++ mobile/src/transport/types.ts | 11 +- .../manifest-contract.test.ts | 18 + .../mobile-web-bundle/manifest-contract.ts | 10 +- 40 files changed, 2099 insertions(+), 326 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge-host-native-verbs.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge-host-native-verbs.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts create mode 100644 mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx create mode 100644 mobile/src/mobile-web-shell/bridge/use-native-verbs.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session-grants.test.ts create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts create mode 100644 mobile/src/platform/native-clipboard.test.ts create mode 100644 mobile/src/platform/native-clipboard.ts diff --git a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx index 000c607849a..f39273da7ab 100644 --- a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx @@ -55,5 +55,11 @@ export default function MobileAgentSessionHistoryScreen() { if (!BridgeInitRouteSchema.safeParse(route).success) { return panel } - return + // Keyed on the route: a host captures the grants its session was opened with, so a screen + // reused across a route change would keep authorising frames under the grants of the route the + // page has left. The key is what makes the change a remount, which disposes that bridge in the + // commit, and the new session starts with no grants until its own `init`. + return ( + + ) } diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 7322b69c5f1..8789353d1a8 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -29,6 +29,9 @@ function HostListScreen() { } return ( } diff --git a/mobile/app/h/[hostId]/web.tsx b/mobile/app/h/[hostId]/web.tsx index 798a41e71fb..8e3cfccc39c 100644 --- a/mobile/app/h/[hostId]/web.tsx +++ b/mobile/app/h/[hostId]/web.tsx @@ -42,6 +42,9 @@ export default function MobileWebShellRoute() { // decodes it back when it matches `[hostId]`, so the screen it opens is the same one. return ( } diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 07f60dac84f..9b63479384e 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -19,6 +19,7 @@ type ScreenDependencies = { canGoBack: boolean pathname: string pageRoutes: readonly string[] + routeGrants: readonly string[] lifecycle: string[] state: MobileWebShellSessionState /** Null for every case but the bridge's: with no client the hook builds no host at all. */ @@ -42,6 +43,7 @@ const dependencies = vi.hoisted((): ScreenDependencies => { canGoBack: true, pathname: '/h/host-1', pageRoutes: ['/h/[hostId]'], + routeGrants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write'], lifecycle: [], state: { kind: 'checking' }, client: null @@ -57,6 +59,12 @@ vi.mock('react-native', () => ({ Text: 'Text', View: 'View' })) +// Reaching the real one imports the Expo runtime this test does not have. The screen only passes +// the handler through; what it does with a verb is `native-clipboard.test.ts`. +vi.mock('expo-clipboard', () => ({ + setStringAsync: () => Promise.resolve(true), + getStringAsync: () => Promise.resolve('') +})) vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }) })) @@ -112,6 +120,7 @@ vi.mock('./use-mobile-web-shell-session', () => ({ useMobileWebShellSession: () => ({ state: dependencies.state, pageRoutes: dependencies.pageRoutes, + routeGrants: dependencies.routeGrants, retry: dependencies.retry, reportShellFailure: dependencies.reportShellFailure, reportDocumentLoaded: dependencies.reportDocumentLoaded, diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 6889ac0673f..80629ede5d7 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -15,6 +15,7 @@ import type { } from './mobile-web-shell-session-contract' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' +import { serveNativeClipboardVerb } from '../platform/native-clipboard' import { useShellStackPop } from './use-shell-stack-pop' import { useMobileWebShellSession } from './use-mobile-web-shell-session' import { usePageHostSnapshot } from './use-page-host-snapshot' @@ -140,14 +141,22 @@ export function MobileWebShellScreen({ const insets = useSafeAreaInsets() const router = useRouter() const popShellStack = useShellStackPop() - const { state, pageRoutes, retry, reportShellFailure, reportDocumentLoaded, reportPageReady } = - useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime }) + const { + state, + pageRoutes, + routeGrants, + retry, + reportShellFailure, + reportDocumentLoaded, + reportPageReady + } = useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime }) const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } = usePageHostSnapshot(hostId) const bridge = useMobileWebShellBridge({ hostId, route, pageRoutes, + routeGrants, session: state, snapshot, readStorage, @@ -182,6 +191,8 @@ export function MobileWebShellScreen({ onNavigate: (href: string) => { router.push(href) }, + // Answered on this device and never forwarded; the host holds it to the verb table first. + serveNativeVerb: serveNativeClipboardVerb, // Straight to the system handler. The envelope allowlisted the scheme before this ran, so the // only failure left is a device with nothing registered for it — a `mailto:` on a phone with no // mail account. Reported rather than swallowed: nothing crosses back for a notify, so this is diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index 4fa6137d522..9a4b24ad7d7 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { BridgeRefusal } from './bridge/bridge-caps' import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' +import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' import type { BridgeNotifyRefusal } from './bridge/bridge-notify-grants' /** @@ -57,6 +58,23 @@ export type BridgeHostOptions = { route: BridgeInitRoute /** Every route pattern the shell would render from the page, so the page knows what to keep. */ pageRoutes: readonly string[] + /** + * What the route this session was opened for declared, narrowed to what this shell implements. + * + * This is the session's whole capability, not the app's: `init` grants exactly these plus the + * protocol's own `fault`, and every grant check reads the same list. A route asking for + * navigation does not get the clipboard because some other route needs it. + */ + routeGrants: readonly string[] + /** + * Whether this session already completed a handshake before this host existed. + * + * A host is rebuilt when the client under it changes, and the page on the other side does not + * know: the session id is the same, so it neither re-handshakes nor hears `BridgeShellReplaced`. + * The pre-handshake refusal is about the session, not this object, so a rebuilt host inherits + * what the session already established and serves it. + */ + sessionEstablished: boolean /** The host the page is showing, minus the credential the bridge already carries for it. */ host: BridgeInitHost /** @@ -73,6 +91,14 @@ export type BridgeHostOptions = { * nothing is a dead tap, which is exactly what the grant is supposed to rule out. */ onNavigate: (href: string) => void + /** + * Serves one `native.` verb on this device. Required, because the grant list advertises the verbs + * and a page told it may call one that reaches nothing is the dead tap the grants rule out. + * + * Rejecting is the refusal: the host turns it into an error frame the page's request rejects + * with. Nothing here reaches the desktop. + */ + serveNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise /** * Opens a URL outside the app, which is the whole of the `externalLink` grant. Required for the * reason `onNavigate` is: the grant is issued on the strength of this existing. diff --git a/mobile/src/mobile-web-shell/bridge-host-errors.ts b/mobile/src/mobile-web-shell/bridge-host-errors.ts index a79307e693b..e08b82b616a 100644 --- a/mobile/src/mobile-web-shell/bridge-host-errors.ts +++ b/mobile/src/mobile-web-shell/bridge-host-errors.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import type { BridgeRefusal } from './bridge/bridge-caps' /** Everything the RN host raises on its own, as opposed to what it forwards from the client. */ @@ -5,6 +6,9 @@ import type { BridgeRefusal } from './bridge/bridge-caps' /** The bridge went away with a request still on it. Carried to the page as delivery-unknown: the * desktop may already have run it. */ export class BridgeHostDisposedError extends Error { + /** Carried so the page names this rather than falling through to an unknown reason. */ + readonly code = 'bridge_host_disposed' + constructor() { super('the page bridge was torn down before this request answered') this.name = 'BridgeHostDisposedError' @@ -13,6 +17,8 @@ export class BridgeHostDisposedError extends Error { /** A page over a cap `init` already told it. Refusing the newcomer leaves what it collided with. */ export class BridgeCapExceededError extends Error { + readonly code = 'bridge_cap_exceeded' + constructor(message: string) { super(message) this.name = 'BridgeCapExceededError' @@ -21,8 +27,71 @@ export class BridgeCapExceededError extends Error { /** A reply the page's own reader would refuse, failed on the sending side so the page hears why. */ export class BridgeReplyUndeliverableError extends Error { + /** Carried so a page switches on the refusal rather than reading it out of the message. */ + readonly code: BridgeRefusal + constructor(refusal: BridgeRefusal) { super(`the reply could not be delivered to the page (${refusal})`) this.name = 'BridgeReplyUndeliverableError' + this.code = refusal } } + +/** + * A `native.` verb the shell will not serve, in the one vocabulary the desktop does not share. + * + * Distinct from the desktop's `forbidden`, which `MOBILE_RPC_METHOD_ALLOWLIST` answers for any + * method it does not list: a `native.` request that ever reached a desktop would come back under + * that code, so reusing it would make a leaked fence read as an ordinary scope refusal. + */ +export const BRIDGE_NATIVE_REFUSAL_CODES = [ + /** No row in the verb table for the method the page named. */ + 'native_verb_unknown', + /** A verb this page was not granted. */ + 'native_verb_ungranted', + /** Params the verb does not take. */ + 'native_verb_params', + /** A result the verb does not declare, refused before it reaches the page. */ + 'native_verb_result', + /** A shape the table admits and this build does not serve, such as an image mime. */ + 'native_verb_out_of_scope', + /** The handler failed on this device. Its own message stays here; only the code crosses. */ + 'native_verb_failed', + /** A `native.` method on a `subscribe`, which this seam answers on requests only. */ + 'native_verb_not_a_stream' +] as const + +export type BridgeNativeRefusalCode = (typeof BRIDGE_NATIVE_REFUSAL_CODES)[number] + +/** + * A `native.` verb the shell will not serve, in a vocabulary the desktop does not share. + * + * Every arm has its own code because the message is not a contract: a page deciding what to do + * about an out-of-scope mime and one whose clipboard could not be read want different things, and + * telling them apart by message text is how that decision rots. + * + * None of these collide with the desktop's `forbidden`, which `MOBILE_RPC_METHOD_ALLOWLIST` + * answers for an unlisted method, so a leaked fence can never read as a scope refusal. + */ +export class BridgeNativeVerbRefusedError extends Error { + readonly code: BridgeNativeRefusalCode + + constructor(code: BridgeNativeRefusalCode, message: string) { + super(message) + this.name = 'BridgeNativeVerbRefusedError' + this.code = code + } +} + +/** + * The code a shell-side handler set on its own failure, if it set one this seam knows. + * + * Parsed rather than reached for: the value is whatever a handler threw, and the anti-slop rule is + * the same one the page's reader follows — name the shape before reading it. + */ +const shellRefusalSchema = z.object({ code: z.enum(BRIDGE_NATIVE_REFUSAL_CODES) }) + +export function readShellRefusalCode(error: unknown): BridgeNativeRefusalCode | null { + const read = shellRefusalSchema.safeParse(error) + return read.success ? read.data.code : null +} diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index 2154f0a02e6..92760591ee0 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -37,7 +37,14 @@ describe('init and state', () => { maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS }, // What the shell will do for the page, and what makes its `navigate` frame acceptable. - native: [BRIDGE_FAULT_GRANT, 'navigate', 'storage', 'externalLink'] + native: [ + BRIDGE_FAULT_GRANT, + 'navigate', + 'storage', + 'externalLink', + 'native.clipboard.write', + 'native.clipboard.read' + ] }, route: ROUTE, pageRoutes: PAGE_ROUTES, diff --git a/mobile/src/mobile-web-shell/bridge-host-native-verbs.test.ts b/mobile/src/mobile-web-shell/bridge-host-native-verbs.test.ts new file mode 100644 index 00000000000..7cd6b19541e --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-native-verbs.test.ts @@ -0,0 +1,329 @@ +/** + * The fence: a `native.` request is answered here and never reaches the desktop. + * + * Every case reads `client.requests`, because that is the only thing that tells a fence which held + * from one which leaked into a refusal that merely looks right. The desktop would refuse the + * method too — it is absent from `MOBILE_RPC_METHOD_ALLOWLIST`, which answers `forbidden` — so a + * leak would come back looking like an ordinary scope refusal. + */ +import { describe, expect, it } from 'vitest' +import { ID, harness } from './bridge-host-test-harness' +import { bridgeId, clientFrame, flushBridge } from './bridge-host-test-fakes' +import { BRIDGE_MAX_PENDING_REQUESTS } from './bridge/bridge-caps' + +function request(method: string, params?: unknown): string { + return params === undefined + ? clientFrame({ type: 'request', id: ID, method }) + : clientFrame({ type: 'request', id: ID, method, params }) +} + +/** The body of a whole reply, or null when the last frame was not one. A chunked reply has no + * `payload`, which is why this narrows on the field rather than on `type` alone. */ +function replyPayload(bridge: ReturnType): Record | null { + const frame = bridge.last() + return frame.type === 'reply' && 'payload' in frame ? frame.payload : null +} + +/** The error a refused verb came back as, or null when the frame was not an error. */ +function refusal(bridge: ReturnType): { code?: unknown; message: string } | null { + const frame = bridge.last() + return frame.type === 'error' ? { code: frame.error.code, message: frame.error.message } : null +} + +/** + * The fence is about the method name, not the frame kind. + * + * `client.requests` staying empty is only half an oracle: a `subscribe` reaches the same client by + * another door and leaves that list untouched, so every case below reads the streams too. + */ +function reachedTheDesktop(bridge: ReturnType): unknown[] { + return [...bridge.client.requests, ...bridge.client.streams] +} + +describe('a native method on a frame that is not a request', () => { + it('opens no stream on the desktop client', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'native.clipboard.read', params: {} }) + ) + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toMatch(/^native_verb_/) + }) + + it('names the collision, not the fence, when the id is one already in flight', async () => { + // Both answers settle the same exchange, so the page loses the request either way; which cause + // it is told is the whole difference between a page bug it can see and one it cannot. + const bridge = harness({ serveNativeVerb: () => new Promise(() => {}) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'native.clipboard.read', params: {} }) + ) + await flushBridge() + expect(refusal(bridge)?.message).toContain('already in flight') + expect(reachedTheDesktop(bridge)).toEqual([]) + }) + + it('refuses an unknown native method on subscribe too, rather than streaming it', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'native.dictation.listen', params: {} }) + ) + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toMatch(/^native_verb_/) + }) +}) + +/** + * A page that has not asked for a session has been told no caps, no grants and no route, so a + * request from it is a frame from a document this host has said nothing to. The notify path has + * refused that since C0; requests did not, for forwarded and native methods alike. + */ +describe('a request before the page has asked for a session', () => { + it('is refused rather than forwarded to the desktop', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.message).toContain('before-ready') + }) + + it('is refused for a native verb too, before the table is even read', async () => { + const bridge = harness() + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(bridge.clipboardWrites).toEqual([]) + expect(refusal(bridge)?.message).toContain('before-ready') + }) + + it('serves a session that handshook with the host this one replaced', async () => { + // A client swap rebuilds the host under a live page. The page does not know: the session id is + // the same, so it neither re-handshakes nor hears that the shell was replaced. Refusing it + // would leave a working page dead until reload, which the gate is not for. + const bridge = harness({ sessionEstablished: true, clipboardText: 'still mine' }) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(replyPayload(bridge)).toEqual({ id: ID, ok: true, result: { value: 'still mine' } }) + }) + + it('serves a stream for such a session too, since the rule is the session and not the frame', () => { + const bridge = harness({ sessionEstablished: true }) + bridge.host.receive(clientFrame({ type: 'subscribe', id: ID, method: 'x.sub', params: {} })) + expect(bridge.client.streams).toHaveLength(1) + }) + + it('refuses a stream on a session that never handshook', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'subscribe', id: ID, method: 'x.sub', params: {} })) + expect(bridge.client.streams).toEqual([]) + expect(refusal(bridge)?.message).toContain('before-ready') + }) + + it('serves the same request once the page has asked', async () => { + const bridge = harness({ clipboardText: 'ready now' }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(replyPayload(bridge)).toEqual({ id: ID, ok: true, result: { value: 'ready now' } }) + }) +}) + +describe('a native method the page asks for', () => { + it('is answered by the shell and never forwarded to the desktop', async () => { + const bridge = harness({ clipboardText: 'from the pasteboard' }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(replyPayload(bridge)).toEqual({ + id: ID, + ok: true, + result: { value: 'from the pasteboard' } + }) + }) + + it('carries no _meta, because no runtime produced it', async () => { + const bridge = harness({ clipboardText: 'x' }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + const payload = replyPayload(bridge) + expect(payload).not.toBeNull() + expect(payload !== null && Object.hasOwn(payload, '_meta')).toBe(false) + }) + + it('writes the text it was handed and answers whether it landed', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.write', { mime: 'text', value: 'copied' })) + await flushBridge() + expect(bridge.clipboardWrites).toEqual(['copied']) + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(replyPayload(bridge)).toEqual({ id: ID, ok: true, result: { written: true } }) + }) + + it('refuses a verb this shell has no row for, before anything is forwarded', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.dictation.start', {})) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toMatch(/^native_verb_/) + }) + + it('refuses params the verb does not take', async () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.write', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toMatch(/^native_verb_/) + }) + + it('refuses a result the verb does not declare, rather than passing it to the page', async () => { + // The table says what a verb answers; without this the claim was decoration and a handler + // could hand the page any shape at all. + const bridge = harness({ serveNativeVerb: () => Promise.resolve({ nonsense: 1 }) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toMatch(/^native_verb_/) + expect(replyPayload(bridge)).toBeNull() + }) + + it('turns a handler that rejects into an error frame, still forwarding nothing', async () => { + const bridge = harness({ + serveNativeVerb: () => Promise.reject(new Error('the pasteboard is unavailable')) + }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)).not.toBeNull() + }) + + it('does not carry a handler message to the page, which could be what it just read', async () => { + // A handler that puts pasteboard text in its message would otherwise hand that text back + // through the error frame, which is the one path out of this seam that is not a result. + const secret = 'sk-live-not-for-the-page' + const bridge = harness({ serveNativeVerb: () => Promise.reject(new Error(secret)) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(JSON.stringify(bridge.posted)).not.toContain(secret) + }) + + it('takes a slot each, so the one over the cap is refused like any other request', async () => { + // A handler that never settles, so every call stays in flight and the cap is what answers. + const bridge = harness({ serveNativeVerb: () => new Promise(() => {}) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + for (let index = 1; index <= BRIDGE_MAX_PENDING_REQUESTS; index += 1) { + bridge.host.receive( + clientFrame({ + type: 'request', + id: bridgeId(index), + method: 'native.clipboard.read', + params: { mime: 'text' } + }) + ) + } + expect(bridge.frames()).toEqual([expect.objectContaining({ type: 'init' })]) + bridge.host.receive( + clientFrame({ + type: 'request', + id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS + 1), + method: 'native.clipboard.read', + params: { mime: 'text' } + }) + ) + const overCap = bridge.last() + expect(overCap.type === 'error' && overCap.error.message).toContain( + `over ${BRIDGE_MAX_PENDING_REQUESTS} requests` + ) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + }) + + it('settles a cancelled native request the way a forwarded one settles, with no late reply', async () => { + const settle: { resolve: ((value: unknown) => void) | null } = { resolve: null } + const bridge = harness({ + serveNativeVerb: () => + new Promise((resolve) => { + settle.resolve = resolve + }) + }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'request' })) + const afterCancel = bridge.frames().length + settle.resolve?.({ value: 'too late' }) + await flushBridge() + // The page moved on from this id; a reply posted now would answer an exchange it no longer has. + expect(bridge.frames()).toHaveLength(afterCancel) + expect(reachedTheDesktop(bridge)).toEqual([]) + }) + + it('refuses a read the page could never receive, rather than truncating it', async () => { + const bridge = harness({ clipboardText: 'a'.repeat(9 * 1024 * 1024) }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + // The reply byte cap every forwarded reply gets, applied by the same `sendReply`. + expect(refusal(bridge)?.message).toContain('reply-too-large') + }) +}) + +/** + * A session is granted what its route asked for, not what the app can do. + * + * Before this the host handed every page the whole capability set, so a route declaring only + * navigation and storage could still read the clipboard. Harmless while every grant was something + * the page could do anyway; not harmless once a verb reads something back. + */ +describe('grants scoped to the route the page was opened for', () => { + const navigationOnly = ['navigate', 'storage'] as const + + it('grants a route only what it declared', () => { + const bridge = harness({ routeGrants: navigationOnly }) + bridge.host.receive(clientFrame({ type: 'ready' })) + const init = bridge.last() + expect(init.type === 'init' && init.grants.native).toEqual(['fault', 'navigate', 'storage']) + }) + + it('refuses a verb that route never asked for', async () => { + const bridge = harness({ routeGrants: navigationOnly, ready: true }) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(reachedTheDesktop(bridge)).toEqual([]) + expect(refusal(bridge)?.code).toBe('native_verb_ungranted') + }) + + it('serves it for a route that did ask', async () => { + const bridge = harness({ + routeGrants: [...navigationOnly, 'native.clipboard.read'], + clipboardText: 'granted', + ready: true + }) + bridge.host.receive(request('native.clipboard.read', { mime: 'text' })) + await flushBridge() + expect(replyPayload(bridge)).toEqual({ id: ID, ok: true, result: { value: 'granted' } }) + }) + + it('refuses a notify that route never asked for, under the protocol name', () => { + const bridge = harness({ routeGrants: ['storage'], ready: true }) + bridge.host.receive( + clientFrame({ type: 'notify', name: 'externalLink', url: 'https://example.com/' }) + ) + expect(bridge.externalLinks).toEqual([]) + expect(bridge.diagnostics).toContainEqual({ + kind: 'notify-refused', + name: 'externalLink', + why: 'ungranted' + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-host-native-verbs.ts b/mobile/src/mobile-web-shell/bridge-host-native-verbs.ts new file mode 100644 index 00000000000..85de4675693 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-native-verbs.ts @@ -0,0 +1,72 @@ +import type { RpcResponse } from '../transport/types' +import { BridgeNativeVerbRefusedError, readShellRefusalCode } from './bridge-host-errors' +import { + BRIDGE_NATIVE_VERB_REFUSAL_CODES, + BRIDGE_NATIVE_VERBS, + readBridgeNativeVerbCall, + type BridgeNativeVerb +} from './bridge/bridge-native-verbs' + +export type NativeVerbServerDeps = { + /** What `init` advertised, which is what a page may call. */ + granted: readonly string[] + /** Serves one verb on this device. Rejecting is how it refuses. */ + serveVerb: (verb: BridgeNativeVerb, params: unknown) => Promise +} + +/** + * The shell-answered half of the request port: a `native.` method decided, served and shaped into + * a reply, without the desktop client being involved at any point. + * + * Split from the host because it is a whole decision of its own — read the call, serve it, hold the + * answer to what the verb declares — and the host's job is the frames around it. + */ +export function createNativeVerbServer( + deps: NativeVerbServerDeps +): (id: string, method: string, params: unknown) => Promise { + /** + * A handler's own failure, re-raised under this seam's vocabulary with a message of our making. + * + * The handler's words do not cross. It is the one thing here holding data the page asked for and + * may not have — a clipboard read that failed after reading is free to put what it read in its + * message — and an error frame is the only path out of this seam that is not a declared result. + * Its code does cross, because an out-of-scope mime and a device that failed are different + * things to act on. The shell keeps the real message, where a device log can show it. + */ + async function serve(verb: BridgeNativeVerb, params: unknown): Promise { + try { + return await deps.serveVerb(verb, params) + } catch (error) { + console.warn('[web-shell-bridge] a native verb failed on this device', { verb }, error) + throw new BridgeNativeVerbRefusedError( + readShellRefusalCode(error) ?? 'native_verb_failed', + `${verb} could not be served on this device` + ) + } + } + + return async (id, method, params) => { + const call = readBridgeNativeVerbCall({ method, granted: deps.granted, params }) + if (!call.ok) { + throw new BridgeNativeVerbRefusedError( + BRIDGE_NATIVE_VERB_REFUSAL_CODES[call.refusal], + call.detail + ) + } + const answered = await serve(call.verb, call.params) + // The table declares what a verb answers, and without this that claim was decoration: a + // handler could hand the page any shape and the page's own parse would be the first to notice, + // halfway through a screen. + const result = BRIDGE_NATIVE_VERBS[call.verb].result.safeParse(answered) + if (!result.success) { + throw new BridgeNativeVerbRefusedError( + 'native_verb_result', + `${call.verb} answered a result it does not declare` + ) + } + // Built as an `RpcResponse` so it rides `sendReply` like any other reply: that is what applies + // `BRIDGE_MAX_REPLY_BYTES`, so a clipboard too large for the page is refused rather than + // truncated. No `_meta` — no runtime produced this, and `isRpcResponse` does not require one. + return { id, ok: true, result: result.data } + } +} diff --git a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts index 67de3d9ad31..5789f5445db 100644 --- a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts @@ -176,7 +176,7 @@ describe('notifications, refusals and the fence', () => { it('forwards to the client it was built with, whatever the frame names', () => { const mine = createFakeRpcClient() const theirs = createFakeRpcClient() - const bridge = harness({ client: mine }) + const bridge = harness({ ready: true, client: mine }) harness({ client: theirs }) bridge.host.receive( clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' }) @@ -186,7 +186,7 @@ describe('notifications, refusals and the fence', () => { }) it('carries no host name into the client message it parsed', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive( clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' }) ) diff --git a/mobile/src/mobile-web-shell/bridge-host-requests.ts b/mobile/src/mobile-web-shell/bridge-host-requests.ts index c87b25a3470..337ed0e9c38 100644 --- a/mobile/src/mobile-web-shell/bridge-host-requests.ts +++ b/mobile/src/mobile-web-shell/bridge-host-requests.ts @@ -4,6 +4,7 @@ import type { RpcResponse } from '../transport/types' import { BRIDGE_MAX_PENDING_REQUESTS } from './bridge/bridge-caps' import type { BridgeClientMessage } from './bridge/bridge-envelope' import { BridgeHostDisposedError } from './bridge-host-errors' +import { isBridgeNativeMethod } from './bridge/bridge-native-verbs' type RequestMessage = Extract @@ -18,6 +19,8 @@ export type BridgeHostRequestDeps = { sendReply: (id: string, payload: RpcResponse) => void sendError: (id: string, error: unknown) => void capExceeded: (message: string) => Error + /** Answers a `native.` method on this device. Rejecting is how the seam refuses one. */ + serveNative: (id: string, method: string, params: unknown) => Promise } /** @@ -56,9 +59,16 @@ export class BridgeHostRequests { } /** The arity the page used, replayed exactly: `sendRequest(m)` and `sendRequest(m, undefined)` - * are different calls to the golden recorder. */ + * are different calls to the golden recorder. + * + * The `native.` fence is here because this is the one place a *request* reaches the client. A + * `subscribe` reaches it by another door and is fenced in `handleSubscribe`; the two together + * are the whole boundary, and a test that reads only `client.requests` sees only this half. */ private forward(message: RequestMessage): Promise { const { client } = this.deps + if (isBridgeNativeMethod(message.method)) { + return this.deps.serveNative(message.id, message.method, message.params) + } if (message.options !== undefined) { return client.sendRequest(message.method, message.params, message.options) } diff --git a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts index a6d51723b84..848af24a91b 100644 --- a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts @@ -8,6 +8,12 @@ import { } from './bridge-host-test-fakes' import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host' import type { BridgeNavigateBackOutcome } from './bridge-host-contract' +import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy' +import { + BRIDGE_NATIVE_VERBS, + clipboardWriteParamsSchema, + type BridgeNativeVerb +} from './bridge/bridge-native-verbs' import { readBridgeHostMessage, type BridgeHostMessage, @@ -26,6 +32,8 @@ export type Harness = { navigations: string[] /** Every URL the page asked the shell to open outside the app, in order. */ externalLinks: string[] + /** Every text the page wrote to the pasteboard through a native verb, in order. */ + clipboardWrites: string[] /** One entry per `navigate-back` the host answered, in order, with what the shell did. */ backPops: BridgeNavigateBackOutcome[] storageWrites: { key: string; value: string | null }[] @@ -51,6 +59,20 @@ export function harness( /** For the suites that need the map to change between two `init` answers. */ readStorage?: () => Readonly> onPageFault?: (error: BridgeErrorCapture) => void + /** + * Whether to answer a `ready` before the case runs, which is what a real page does first: the + * host serves no request until it has issued an `init`. Off by default so a case about the + * pre-ready refusals can still be written. + */ + /** What the mounted route declared; everything this shell implements unless a case narrows it. */ + routeGrants?: readonly string[] + /** Stands for a host rebuilt under a page whose session already handshook. */ + sessionEstablished?: boolean + ready?: boolean + /** What the pasteboard answers a read with. */ + clipboardText?: string + /** Replaces the whole verb handler, for the arm where a device call fails. */ + serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise } = {} ): Harness { const client = options.client ?? createFakeRpcClient() @@ -58,6 +80,7 @@ export function harness( const diagnostics: BridgeHostDiagnostic[] = [] const navigations: string[] = [] const externalLinks: string[] = [] + const clipboardWrites: string[] = [] const backPops: BridgeNavigateBackOutcome[] = [] const storageWrites: { key: string; value: string | null }[] = [] let pageReadies = 0 @@ -73,6 +96,8 @@ export function harness( sessionId: 'session-a', route: options.route ?? ROUTE, pageRoutes: PAGE_ROUTES, + routeGrants: options.routeGrants ?? MOBILE_WEB_SHELL_GRANTS, + sessionEstablished: options.sessionEstablished ?? false, host: HOST, readStorage: options.readStorage ?? (() => options.storage ?? {}), onStorageWrite: (key, value) => storageWrites.push({ key, value }), @@ -82,6 +107,18 @@ export function harness( onRouteRefused: (issue) => routeRefusals.push(issue), onNavigate: options.onNavigate ?? ((href) => navigations.push(href)), onExternalLink: (url) => externalLinks.push(url), + serveNativeVerb: (verb, params) => { + if (options.serveNativeVerb !== undefined) { + return options.serveNativeVerb(verb, params) + } + const read = BRIDGE_NATIVE_VERBS[verb].params.parse(params) + if (verb === 'native.clipboard.write') { + const { value } = clipboardWriteParamsSchema.parse(read) + clipboardWrites.push(value) + return Promise.resolve({ written: true }) + } + return Promise.resolve({ value: options.clipboardText ?? '' }) + }, onNavigateBack: () => { const outcome = options.onNavigateBack?.() ?? 'popped' backPops.push(outcome) @@ -93,6 +130,9 @@ export function harness( }, onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }) + if (options.ready === true) { + host.receive(clientFrame({ type: 'ready' })) + } // Read back through the page's own reader: a frame the host sends that the page would refuse is // a frame that never arrives, and this is the only place both halves meet in one test. const frames = (): BridgeHostMessage[] => @@ -110,6 +150,7 @@ export function harness( diagnostics, navigations, externalLinks, + clipboardWrites, backPops, storageWrites, pageReadyCount: () => pageReadies, diff --git a/mobile/src/mobile-web-shell/bridge-host.test.ts b/mobile/src/mobile-web-shell/bridge-host.test.ts index 23a859c8bcf..2a8a7a230af 100644 --- a/mobile/src/mobile-web-shell/bridge-host.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host.test.ts @@ -20,7 +20,7 @@ import { BridgeReplyAssembler } from './bridge/bridge-reply-chunking' describe('requests', () => { it('replays the arity the page used', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) bridge.host.receive( clientFrame({ type: 'request', id: OTHER, method: 'status.get', params: undefined }) @@ -45,7 +45,7 @@ describe('requests', () => { }) it('carries a host failure through as data, _meta and error.data included', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) const failure: RpcResponse = { id: 'wire-1', @@ -59,7 +59,7 @@ describe('requests', () => { }) it('turns a rejection into the five-field capture, delivery mark and cause included', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) const cause = new Error('socket closed') const error = new TypeError('send failed') @@ -82,6 +82,7 @@ describe('requests', () => { it('answers a synchronous throw from the client and frees the slot', () => { const client = createFakeRpcClient() const bridge = harness({ + ready: true, client: { ...client, sendRequest: () => { @@ -99,7 +100,7 @@ describe('requests', () => { }) it('refuses an id already in flight without settling the exchange it collided with', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'other.get' })) expect(bridge.client.requests).toHaveLength(1) @@ -123,14 +124,15 @@ describe('requests', () => { }) it('admits exactly the in-flight cap and refuses the next', () => { - const bridge = harness() + const bridge = harness({ ready: true }) for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { bridge.host.receive( clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' }) ) } expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS) - expect(bridge.posted).toHaveLength(0) + // The `init` this page's session opened with, and nothing else: none of these was refused. + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([]) bridge.host.receive( clientFrame({ type: 'request', id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS), method: 'x.get' }) ) @@ -139,7 +141,7 @@ describe('requests', () => { }) it('reopens a slot when a request settles', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) { bridge.host.receive( clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' }) @@ -181,18 +183,18 @@ describe('requests', () => { }) it('stops answering a cancelled request without pretending the desktop stopped running it', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'request' })) bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) await flushBridge() - expect(bridge.posted).toHaveLength(0) + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([]) }) }) describe('replies too big for one frame', () => { it('chunks and reassembles to the same payload', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' })) const payload = rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_MESSAGE_BYTES * 2)) bridge.client.requests[0]?.resolve(payload) @@ -207,7 +209,7 @@ describe('replies too big for one frame', () => { }) it('aborts the request over the reply ceiling rather than truncating an answer', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' })) bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_REPLY_BYTES + 1))) await flushBridge() @@ -221,31 +223,31 @@ describe('replies too big for one frame', () => { describe('subscriptions', () => { it('forwards with the arity the recorder reads and streams events from seq 1', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) expect(bridge.client.streams[0]?.method).toBe('terminal.subscribe') bridge.client.streams[0]?.emit({ chunk: 'a' }) bridge.client.streams[0]?.emit({ chunk: 'b' }) - expect(bridge.frames()).toEqual([ + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([ { v: 1, type: 'event', id: ID, seq: 1, payload: { chunk: 'a' } }, { v: 1, type: 'event', id: ID, seq: 2, payload: { chunk: 'b' } } ]) }) it('admits exactly the subscription cap and refuses the next', () => { - const bridge = harness() + const bridge = harness({ ready: true }) for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { bridge.host.receive(subscribeFrame(bridgeId(index))) } expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) - expect(bridge.posted).toHaveLength(0) + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([]) bridge.host.receive(subscribeFrame(bridgeId(BRIDGE_MAX_SUBSCRIPTIONS))) expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS) expect(bridge.last().type).toBe('error') }) it('reopens a slot when a stream is cancelled', () => { - const bridge = harness() + const bridge = harness({ ready: true }) for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) { bridge.host.receive(subscribeFrame(bridgeId(index))) } @@ -255,7 +257,7 @@ describe('subscriptions', () => { }) it('unsubscribes on cancel, says so, and delivers nothing after', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) bridge.client.streams[0]?.emit({ chunk: 'a' }) bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'subscription' })) @@ -285,6 +287,7 @@ describe('subscriptions', () => { const client = createFakeRpcClient() let unsubscribes = 0 const bridge = harness({ + ready: true, client: { ...client, subscribe: (_method, _params, onData) => { @@ -296,7 +299,9 @@ describe('subscriptions', () => { } }) bridge.host.receive(subscribeFrame(ID)) - expect(bridge.frames()).toEqual([{ v: 1, type: 'end', id: ID, reason: 'overflow' }]) + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([ + { v: 1, type: 'end', id: ID, reason: 'overflow' } + ]) // The stream was already retired when its unsubscribe arrived, so storing it on the record // would leak the client's stream with nothing left to read it. expect(unsubscribes).toBe(1) @@ -311,7 +316,7 @@ describe('backpressure', () => { } it('sends exactly the unacked frame window and then ends with overflow', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength( @@ -323,7 +328,7 @@ describe('backpressure', () => { }) it('reopens the window on ack', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: BRIDGE_MAX_UNACKED_FRAMES })) @@ -334,7 +339,7 @@ describe('backpressure', () => { }) it('acks only up to the seq it was given', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 })) @@ -347,7 +352,7 @@ describe('backpressure', () => { }) it('ends on the unacked byte window well before the frame window is reached', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) const ended = (): boolean => (bridge.posted.at(-1) ?? '').includes('"type":"end"') @@ -366,7 +371,7 @@ describe('backpressure', () => { }) it('reopens the byte window on ack, not just the frame window', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) // What fits under the byte window, which leaves the next frame of this size to overflow it. @@ -389,14 +394,14 @@ describe('backpressure', () => { }) it('ends rather than posting an event the page would refuse as oversized', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) bridge.client.streams[0]?.emit('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) }) it('keeps each stream on its own window', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) bridge.host.receive(subscribeFrame(OTHER)) for (let index = 0; index <= BRIDGE_MAX_UNACKED_FRAMES; index += 1) { @@ -409,17 +414,18 @@ describe('backpressure', () => { describe('teardown', () => { it('rejects every pending as delivery-unknown, ends every stream, and refuses later frames', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) bridge.host.receive(subscribeFrame(OTHER)) bridge.host.dispose() - expect(bridge.frames()).toEqual([ + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([ { v: 1, type: 'error', id: ID, error: { category: 'BridgeHostDisposedError', + code: 'bridge_host_disposed', message: 'the page bridge was torn down before this request answered', isRpcDeliveryUnknown: true } @@ -435,7 +441,8 @@ describe('teardown', () => { bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' })) bridge.host.receive(clientFrame({ type: 'ready' })) await flushBridge() - expect(bridge.frames()).toHaveLength(2) + // The `init` this session opened with, plus the two the disposal settled, and nothing since. + expect(bridge.frames()).toHaveLength(3) expect(bridge.client.requests).toHaveLength(1) expect(bridge.client.streams).toHaveLength(1) expect(bridge.client.foregroundCalls).toEqual([]) @@ -446,25 +453,25 @@ describe('teardown', () => { }) it('is idempotent', () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(subscribeFrame(ID)) bridge.host.dispose() bridge.host.dispose() - expect(bridge.frames()).toHaveLength(1) + expect(bridge.frames()).toHaveLength(2) expect(bridge.client.streams[0]?.unsubscribes).toBe(1) }) it('settles what the page owned on close without answering a page that said goodbye', async () => { - const bridge = harness() + const bridge = harness({ ready: true }) bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' })) bridge.host.receive(subscribeFrame(OTHER)) bridge.host.receive(clientFrame({ type: 'close' })) - expect(bridge.posted).toHaveLength(0) + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([]) expect(bridge.client.streams[0]?.unsubscribes).toBe(1) bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) bridge.client.streams[0]?.emit({ chunk: 'a' }) await flushBridge() - expect(bridge.posted).toHaveLength(0) + expect(bridge.frames().filter((frame) => frame.type !== 'init')).toEqual([]) }) it('answers the document that loads in after a close, rather than latching shut', () => { diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index 27d78702094..eff722d7a15 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -1,5 +1,11 @@ import type { ConnectionState, RpcResponse } from '../transport/types' -import { BridgeCapExceededError, BridgeReplyUndeliverableError } from './bridge-host-errors' +import { + BridgeCapExceededError, + BridgeNativeVerbRefusedError, + BridgeReplyUndeliverableError +} from './bridge-host-errors' +import { isBridgeNativeMethod } from './bridge/bridge-native-verbs' +import { createNativeVerbServer } from './bridge-host-native-verbs' import { BridgeHostRequests } from './bridge-host-requests' import { BridgeHostSubscriptions } from './bridge-host-subscriptions' import { BRIDGE_MAX_SUBSCRIPTIONS, readBridgeExternalLinkUrl } from './bridge/bridge-caps' @@ -15,7 +21,7 @@ import { type BridgeHostMessage } from './bridge/bridge-envelope' import { captureBridgeError } from './bridge/bridge-error-capture' -import { BRIDGE_NATIVE_GRANTS, createBridgeInitFrame } from './bridge/bridge-init-frame' +import { createBridgeInitFrame } from './bridge/bridge-init-frame' import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants' import { splitBridgeReply } from './bridge/bridge-reply-chunking' import { isPageStorageKeyForHost } from './page-storage-keys' @@ -42,6 +48,8 @@ export type BridgeHost = { */ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { const { client, buildId, sessionId, pageRoutes, host } = options + // The protocol's own grant rides with every session; the rest is what this route asked for. + const granted: readonly string[] = [BRIDGE_FAULT_GRANT, ...options.routeGrants] // Parsed here, once, against the same schema the page reads it with. The producer interpolates a // host id into a pathname, so a host id carrying `?`, `#`, whitespace or a dot segment reaches // the wire as a route no page will accept; without this the page refuses the whole `init`, asks @@ -57,7 +65,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { // Whether this host has ever answered a `ready`. Not the same as `serving`, which starts true so // the first document's frames are not refused for arriving in the same batch as its `ready`: this // one starts false, because a page that has been told no grants holds none. - let initSent = false + // Seeded from the session rather than started false: this host may be a rebuild taking over a + // session that handshook with the one before it. + let initSent = options.sessionEstablished let postFailureReported = false let notifyFailureReported = false @@ -137,6 +147,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { connection: snapshot(), route, pageRoutes, + granted, host, storage: options.readStorage() }) @@ -159,17 +170,36 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { isIdTaken: (id) => subscriptions.has(id), sendReply, sendError, - capExceeded: (message) => new BridgeCapExceededError(message) + capExceeded: (message) => new BridgeCapExceededError(message), + serveNative: createNativeVerbServer({ + granted, + serveVerb: (verb, params) => options.serveNativeVerb(verb, params) + }) }) // `wantsBinary` is read by the contract and acted on in C6, which owns the screencast encoder and // the measurement that earns it. Until then every stream crosses as JSON. function handleSubscribe(message: SubscribeMessage): void { const { id } = message + // Collision first: both refusals settle the same exchange, and an id already in flight is the + // truer cause — answering the fence there would kill a live request while naming the method. if (requests.has(id) || subscriptions.has(id)) { sendError(id, new BridgeCapExceededError('that id is already in flight')) return } + // The fence is about the method name, not the frame kind: a `native.` verb is answered here or + // not at all, and a stream is another door to the same client. Still before any slot is taken, + // so nothing about this frame reaches the desktop. + if (isBridgeNativeMethod(message.method)) { + sendError( + id, + new BridgeNativeVerbRefusedError( + 'native_verb_not_a_stream', + `${message.method} is not a stream this shell serves` + ) + ) + return + } if (subscriptions.size >= BRIDGE_MAX_SUBSCRIPTIONS) { sendError(id, new BridgeCapExceededError(`over ${BRIDGE_MAX_SUBSCRIPTIONS} subscriptions`)) return @@ -187,7 +217,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { const refusal = bridgeNotifyRefusal({ name: message.name, initSent, - granted: BRIDGE_NATIVE_GRANTS + granted }) if (refusal !== null) { options.onDiagnostic?.({ kind: 'notify-refused', name: message.name, why: refusal }) @@ -293,6 +323,15 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { options.onDiagnostic?.({ kind: 'frame-after-close' }) return } + // A page whose session has never handshook has been told no caps, no grants and no route, so + // anything it opens is a frame from a document nothing has answered. The notify path has + // refused that since C0 under the same name; requests and streams did not. Keyed on the + // session, so a host rebuilt under a live page serves it rather than refusing until reload. + if (!initSent && (message.type === 'request' || message.type === 'subscribe')) { + options.onDiagnostic?.({ kind: 'notify-refused', name: message.method, why: 'before-ready' }) + sendError(message.id, new BridgeCapExceededError('before-ready')) + return + } switch (message.type) { case 'request': requests.open(message) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts index 835725cb542..2bbeac381a3 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-errors.ts @@ -52,3 +52,15 @@ export class BridgeSendFailedError extends Error { this.name = 'BridgeSendFailedError' } } + +/** A method sent through the native-verb member that is not one. The member is typed, so this is + * reachable only from a caller that widened it; refusing keeps the member from being a raw port. */ +export class BridgeClientNotNativeVerbError extends Error { + /** Named for the page, so this does not fall through to an unknown reason. */ + readonly code = 'native_verb_not_a_verb' + + constructor(method: string) { + super(`${method} is not a native verb`) + this.name = 'BridgeClientNotNativeVerbError' + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts index 246c4be21dc..59adbc06265 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts @@ -10,7 +10,10 @@ import { } from './bridge-envelope' /** - * What `init` offers every page. + * Every grant this app implements, which is the ceiling a session's own list is drawn from. A page + * is granted the intersection of this and what its route declared, never this. + * + * What `init` offers a page. * * A name added here is never a version bump; a page that does not know one simply never posts it. * `fault` leads because it is the protocol's rather than a screen's: every page gets it and no @@ -31,6 +34,8 @@ export function createBridgeInitFrame(args: { route: BridgeInitRoute /** The route patterns the page keeps for itself; everything else comes back as `navigate`. */ pageRoutes: readonly string[] + /** What this session may do: the protocol's own grant plus what its route declared. */ + granted: readonly string[] /** The host the page is showing, minus the credential the bridge already carries for it. */ host: BridgeInitHost /** The allowlisted keys as the app holds them right now. */ @@ -49,7 +54,7 @@ export function createBridgeInitFrame(args: { }, // Copied, not shared: the list the host enforces must not be reachable through a frame it // hands out. - native: [...BRIDGE_NATIVE_GRANTS] + native: [...args.granted] }, route: args.route, pageRoutes: [...args.pageRoutes], diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts new file mode 100644 index 00000000000..80b48161e4d --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts @@ -0,0 +1,95 @@ +/** The decision the seam makes before anything is dispatched, as a function of its inputs. */ +import { describe, expect, it } from 'vitest' +import { + BRIDGE_NATIVE_METHOD_PREFIX, + BRIDGE_NATIVE_VERB_NAMES, + isBridgeNativeMethod, + readBridgeNativeVerbCall +} from './bridge-native-verbs' + +const ALL = [...BRIDGE_NATIVE_VERB_NAMES] + +describe('which methods the seam claims', () => { + it('claims every name under the prefix, whether or not this build serves it', () => { + // Claiming an unknown `native.` method is the point: it is refused here rather than forwarded, + // so a desktop never sees it and its `forbidden` can never be mistaken for this refusal. + expect(isBridgeNativeMethod('native.clipboard.read')).toBe(true) + expect(isBridgeNativeMethod('native.dictation.start')).toBe(true) + expect(isBridgeNativeMethod(BRIDGE_NATIVE_METHOD_PREFIX)).toBe(true) + }) + + it('claims nothing else, so every desktop method still forwards', () => { + for (const method of ['worktree.list', 'status.get', 'nativeish.clipboard.read', '']) { + expect(isBridgeNativeMethod(method), method).toBe(false) + } + }) +}) + +describe('reading a native verb call', () => { + it('answers the verb and its parsed params when everything lines up', () => { + expect( + readBridgeNativeVerbCall({ + method: 'native.clipboard.write', + granted: ALL, + params: { mime: 'text', value: 'copied' } + }) + ).toEqual({ + ok: true, + verb: 'native.clipboard.write', + params: { mime: 'text', value: 'copied' } + }) + }) + + it('names a verb this build has no row for', () => { + const read = readBridgeNativeVerbCall({ + method: 'native.dictation.start', + granted: ALL, + params: {} + }) + expect(read.ok).toBe(false) + expect(read.ok === false && read.refusal).toBe('unknown-verb') + }) + + it('names a verb the page was never granted', () => { + // Unreachable through a real host while every page is offered every verb, and the whole point + // of the check the moment a grant is per-route. + const read = readBridgeNativeVerbCall({ + method: 'native.clipboard.read', + granted: ['native.clipboard.write'], + params: { mime: 'text' } + }) + expect(read.ok).toBe(false) + expect(read.ok === false && read.refusal).toBe('ungranted') + }) + + it('names params the verb does not take, before any handler sees them', () => { + for (const params of [ + {}, + { mime: 'text' }, + { mime: 'audio', value: 'x' }, + null, + // A key the shell does not know. Stripped rather than refused, a page believing it meant + // something would have been served as if it had not sent it. + { mime: 'text', value: 'x', unexpected: true } + ]) { + const read = readBridgeNativeVerbCall({ + method: 'native.clipboard.write', + granted: ALL, + params + }) + expect(read.ok, JSON.stringify(params)).toBe(false) + expect(read.ok === false && read.refusal).toBe('invalid-params') + } + }) + + it('takes an image on the wire, because the shape is broad and the handler is not', () => { + // The mime is valid here and refused by the handler: that is what lets a later build serve it + // without a contract change. + const read = readBridgeNativeVerbCall({ + method: 'native.clipboard.read', + granted: ALL, + params: { mime: 'image' } + }) + expect(read.ok).toBe(true) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts new file mode 100644 index 00000000000..c8f918534fe --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts @@ -0,0 +1,132 @@ +import { z } from 'zod' + +/** + * The shell-answered request seam: what a `native.` method is, and every verb there is. + * + * A page `request` whose method starts with this prefix is answered by the host and never reaches + * the desktop. That fence is the load-bearing one. The desktop would refuse the method too — it is + * absent from `MOBILE_RPC_METHOD_ALLOWLIST`, which answers `forbidden` for anything unlisted — but + * that only applies to a request that got as far as a paired desktop, and depends on its version. + * The point of the prefix is that the request never leaves the phone, so neither matters. + * + * Replies ride the existing `reply` and `error` frames and count against the same in-flight cap as + * a forwarded request. They carry **no `_meta`**: `isRpcResponse` does not require it on either + * arm, and no runtime produced these, so a page reader must not depend on one being there. + * + * Adding a verb is a row here plus a handler; it is never a new frame kind. + */ +export const BRIDGE_NATIVE_METHOD_PREFIX = 'native.' + +/** Every verb this shell serves. The table below must cover exactly these, or it does not compile. */ +export const BRIDGE_NATIVE_VERB_NAMES = ['native.clipboard.write', 'native.clipboard.read'] as const + +export type BridgeNativeVerb = (typeof BRIDGE_NATIVE_VERB_NAMES)[number] + +/** + * Broad on first addition, per the plan's rule: the shape admits an image because a later build + * will serve one, not because this one does. `image` is refused by name, and the reason says the + * verb is out of scope here rather than unavailable — `expo-clipboard` implements + * `getImageAsync`/`setImageAsync`, so a reason claiming the platform cannot would mislead whoever + * adds it. + */ +export const BRIDGE_CLIPBOARD_MIMES = ['text', 'image'] as const + +export type BridgeClipboardMime = (typeof BRIDGE_CLIPBOARD_MIMES)[number] + +const mimeSchema = z.enum(BRIDGE_CLIPBOARD_MIMES) + +/** + * One verb's wire contract. Params are read from the page and are attacker-shaped; results are the + * shell's own and are declared so a handler cannot answer a shape the page will not parse. + */ +export type BridgeNativeVerbSpec = { + params: z.ZodType + result: z.ZodType +} + +/** + * No cap on the written text beyond the frame's own: a clipboard write is bounded by + * `BRIDGE_MAX_MESSAGE_BYTES` like every other page frame, and a second bound here would refuse + * what the transport already accepted. The read is bounded on the way out instead, by the reply + * byte cap every forwarded reply gets. + */ +/** Exported concretely as well as through the table: a handler parses with the schema for the verb + * it is serving, so what it holds is typed without an assertion. The table's values are widened to + * `ZodType`, which is all the host needs to refuse params before it dispatches. */ +// Strict, not stripping: `z.object` drops a key it does not know, so a call carrying one it thinks +// is meaningful would dispatch as if it had not. The page and the shell are separate builds, and a +// param the shell silently ignores is the shape of a verb that changed under a page. +export const clipboardWriteParamsSchema = z.strictObject({ mime: mimeSchema, value: z.string() }) +export const clipboardReadParamsSchema = z.strictObject({ mime: mimeSchema }) + +export const clipboardWriteResultSchema = z.strictObject({ written: z.boolean() }) +export const clipboardReadResultSchema = z.strictObject({ value: z.string() }) + +export const BRIDGE_NATIVE_VERBS: Readonly> = { + 'native.clipboard.write': { + params: clipboardWriteParamsSchema, + result: clipboardWriteResultSchema + }, + 'native.clipboard.read': { + params: clipboardReadParamsSchema, + result: clipboardReadResultSchema + } +} + +/** Whether a method the page named is one this seam answers rather than one the desktop serves. */ +export function isBridgeNativeMethod(method: string): boolean { + return method.startsWith(BRIDGE_NATIVE_METHOD_PREFIX) +} + +/** The verb a `native.` method names, or null when this shell has no row for it. */ +export function readBridgeNativeVerb(method: string): BridgeNativeVerb | null { + return BRIDGE_NATIVE_VERB_NAMES.find((verb) => verb === method) ?? null +} + +/** Why the seam would not serve a `native.` method. Each is a different fault, so each is named. */ +export type BridgeNativeVerbRefusal = 'unknown-verb' | 'ungranted' | 'invalid-params' + +/** The code each pre-dispatch refusal crosses under. One decision, one name, in one place. */ +export const BRIDGE_NATIVE_VERB_REFUSAL_CODES = { + 'unknown-verb': 'native_verb_unknown', + ungranted: 'native_verb_ungranted', + 'invalid-params': 'native_verb_params' +} as const + +export type BridgeNativeVerbRead = + | { ok: true; verb: BridgeNativeVerb; params: unknown } + | { ok: false; refusal: BridgeNativeVerbRefusal; detail: string } + +/** + * The whole decision, as a function of what the page asked and what it was granted. + * + * Separate from the host so the `ungranted` arm can be exercised at all: every page is offered + * every verb this build implements, so through a real host that arm is unreachable today, and it + * is the whole point of the check the moment a grant is per-route. + */ +export function readBridgeNativeVerbCall(args: { + method: string + granted: readonly string[] + params: unknown +}): BridgeNativeVerbRead { + const verb = readBridgeNativeVerb(args.method) + if (verb === null) { + return { + ok: false, + refusal: 'unknown-verb', + detail: `this build serves no verb named ${args.method}` + } + } + if (!args.granted.includes(verb)) { + return { ok: false, refusal: 'ungranted', detail: `the page was not granted ${verb}` } + } + const read = BRIDGE_NATIVE_VERBS[verb].params.safeParse(args.params) + if (!read.success) { + return { + ok: false, + refusal: 'invalid-params', + detail: `${verb} was called with params it does not take` + } + } + return { ok: true, verb, params: read.data } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts index cb0745cf3d6..f50ddaaefdc 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts @@ -1,6 +1,8 @@ import type { RpcClient } from '../../transport/rpc-client' import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from '../bridge-host' import type { BridgeNavigateBackOutcome } from '../bridge-host-contract' +import type { BridgeNativeVerb } from './bridge-native-verbs' +import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy' import { createFakeRpcClient, type FakeRpcClient } from '../bridge-host-test-fakes' import { readBridgeClientMessage, @@ -81,6 +83,12 @@ export type BridgePortPairOptions = { * the payload itself does. Nothing in the product rewrites a frame in flight. */ rewriteToPage?: (json: string) => string + /** What the mounted route declared; everything this shell implements unless a case narrows it. */ + routeGrants?: readonly string[] + /** Stands for a host rebuilt under a page whose session already handshook. */ + sessionEstablished?: boolean + /** Replaces the verb handler, for the arms where the shell refuses rather than answers. */ + serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise } type Lane = { @@ -173,8 +181,16 @@ export function createBridgePortPair( sessionId: options.sessionId ?? 'session-a', route: options.route ?? { pathname: '/h/host-a' }, pageRoutes: options.pageRoutes ?? ['/h/[hostId]'], + routeGrants: options.routeGrants ?? MOBILE_WEB_SHELL_GRANTS, + sessionEstablished: options.sessionEstablished ?? false, onNavigate: (href) => navigations.push(href), onExternalLink: (url) => externalLinks.push(url), + // The pair has no device: what a test reads here is that the host answered without forwarding. + serveNativeVerb: (verb, params) => + options.serveNativeVerb?.(verb, params) ?? + Promise.resolve( + verb === 'native.clipboard.write' ? { written: true } : { value: 'pasteboard' } + ), onNavigateBack: () => { // A pair has no stack, so the pop always lands: what a test reads here is that the host acted. backPops.push('popped') diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts index 05711d22f6c..013efe7d4c2 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -1,6 +1,6 @@ import type { BrowserScreencastFrame } from '../../transport/browser-screencast-protocol' import type { RpcClient, SendRequestOptions } from '../../transport/rpc-client' -import type { ConnectionState, RpcResponse } from '../../transport/types' +import type { ConnectionState, RpcResponse, RpcSuccess } from '../../transport/types' import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps' import { BridgeConnectionCache } from './bridge-client-connection-cache' import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics' @@ -9,6 +9,7 @@ import { createBridgeInitHandshake } from './bridge-client-init-handshake' import { BridgeClientCapExceededError, BridgeClientClosedError, + BridgeClientNotNativeVerbError, BridgeClientNotReadyError, BridgeSendFailedError, BridgeShellReplacedError @@ -17,6 +18,7 @@ import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames' import { createBridgeClientNotifications } from './bridge-client-notifications' import { BridgeClientRequests } from './bridge-client-requests' import { BridgeClientSubscriptions } from './bridge-client-subscriptions' +import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs' import { BRIDGE_PROTOCOL_VERSION, type BridgeClientMessage, @@ -70,6 +72,14 @@ export type BridgeRpcClient = RpcClient & { * a throw inside a tap handler is not that. */ notifyExternalLink: (url: string) => boolean + /** + * Calls one shell-answered verb. It rides the same `request` frame, id space and in-flight cap + * as a desktop method; the `native.` prefix is what makes the host answer it instead of + * forwarding. It lives here rather than in a screen because that is what keeps the raw request + * port inside the module that owns it — a native verb is bridge machinery, not an RPC to a + * runtime, so it has no `RpcOperation` and no entry in the desktop's method catalog. + */ + callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise /** Writes one allowlisted key into the app's store. False when the shell granted no `storage`. */ notifyStorageWrite: (key: string, value: string | null) => boolean /** @@ -316,6 +326,24 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp notifyNavigate: notifications.notifyNavigate, notifyNavigateBack: notifications.notifyNavigateBack, notifyExternalLink: notifications.notifyExternalLink, + callNativeVerb: (verb, params) => { + // Typed to the table, and checked anyway: the type is the fence for every caller the + // compiler can see, and this is the one for a caller that reached the member through a + // widened one. Without it the member is a raw port the inventory cannot count, because a + // bare-identifier call is not a shape its scan looks for. + if (!isBridgeNativeMethod(verb)) { + return Promise.reject(new BridgeClientNotNativeVerbError(verb)) + } + return sendRequest(verb, params).then((reply) => { + // A refusal crosses as an `error` frame and rejects above, and nothing forwards a native + // method, so no host `RpcFailure` can arrive on one. Narrowed here rather than at every + // caller, which is what lets this member promise a success or a rejection and nothing else. + if (!reply.ok) { + throw new Error(reply.error.message) + } + return reply + }) + }, notifyStorageWrite: notifications.notifyStorageWrite, notifyPageFault: notifications.notifyPageFault, close, diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx b/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx new file mode 100644 index 00000000000..5fc576f73cc --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx @@ -0,0 +1,262 @@ +/** The page's side of the verbs, over the real pair: what it sends, and what it refuses to send. */ +import type { ReactElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The provider module re-exports the screen hooks, and reaching the real ones imports the Expo +// runtime this test does not have. Nothing below calls one. +vi.mock('../../transport/host-client-hooks', () => ({ + useDisconnectHostClient: () => () => {}, + useForceReconnect: () => () => Promise.resolve(), + useForgetHostClient: () => () => {}, + useHostClient: () => ({ client: null, clientId: null, state: 'disconnected' }), + usePrimeHosts: () => () => {}, + useRefreshHostClient: () => () => {} +})) + +import { RpcClientProvider } from '../../transport/client-context.web' +import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope' +import { BRIDGE_NATIVE_VERB_NAMES } from './bridge-native-verbs' +import { createFakeBridgePortPair, type BridgePortPair } from './bridge-port-pair-test-harness' +import { GRANTS, INIT, createPageClient } from './bridge-page-client-test-harness' +import { + NATIVE_VERB_REASONS, + NativeVerbError, + useNativeVerbs, + type NativeVerbs +} from './use-native-verbs' + +const held: { verbs: NativeVerbs | null } = { verbs: null } + +function Screen(): null { + held.verbs = useNativeVerbs() + return null +} + +function render(pair: BridgePortPair): ReactElement { + return ( + + + + ) +} + +async function mount(pair: BridgePortPair): Promise { + await pair.flush() + act(() => { + create(render(pair)) + }) + const verbs = held.verbs + if (verbs === null) { + throw new Error('nothing mounted') + } + return verbs +} + +beforeEach(() => { + held.verbs = null +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a page calling a native verb', () => { + it('writes through the shell and hears whether the pasteboard took it', async () => { + const pair = createFakeBridgePortPair() + const verbs = await mount(pair) + const written = verbs.writeClipboardText('copied') + await pair.flush() + await expect(written).resolves.toBe(true) + // The whole point of the seam: it never reached the desktop's client. + expect(pair.rpc.requests).toEqual([]) + }) + + it('reads through the shell', async () => { + const pair = createFakeBridgePortPair() + const verbs = await mount(pair) + const read = verbs.readClipboardText() + await pair.flush() + await expect(read).resolves.toBe('pasteboard') + expect(pair.rpc.requests).toEqual([]) + }) + + it('sends the verb as an ordinary request, which is why it settles like one', async () => { + const pair = createFakeBridgePortPair() + const verbs = await mount(pair) + void verbs.readClipboardText() + await pair.flush() + const sent = pair.toShell + .map((json: string) => JSON.parse(json)) + .filter((frame: { type?: string }) => frame.type === 'request') + expect(sent).toEqual([ + expect.objectContaining({ type: 'request', method: 'native.clipboard.read' }) + ]) + }) +}) + +describe('a shell that granted no native verbs', () => { + it('refuses before a frame is sent, so the call costs no in-flight slot', async () => { + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: ['navigate'] } }) + act(() => { + create( + + + + ) + }) + const verbs = held.verbs + if (verbs === null) { + throw new Error('nothing mounted') + } + expect(verbs.granted).toBe(false) + const before = page.sent.length + await expect(verbs.readClipboardText()).rejects.toThrow(/did not grant/) + // A rejection after a round trip and one that never left look the same to an `await`; only the + // first would have put a request on the wire. + expect(page.sent).toHaveLength(before) + }) + + it('says so before it is called, so a caller can choose its own fallback', async () => { + const pair = createFakeBridgePortPair() + const verbs = await mount(pair) + expect(verbs.granted).toBe(true) + }) +}) + +/** + * The member exists so the raw port stays inside the module that owns it. That is only true while + * it cannot be used as a raw port: a desktop method sent through it would reach the desktop, and + * the inventory would not see it, because a bare-identifier call is not a shape the scan counts. + */ +describe('the native verb member on the client', () => { + it('refuses a method outside the prefix instead of sending it to the desktop', async () => { + const pair = createFakeBridgePortPair() + await pair.flush() + // The compile-time half, held by the tests-typecheck ratchet: widening the parameter back to + // `string` makes this directive unused and fails there. The call still runs, which is the + // runtime half — a caller that reached the member through a widened type. + // @ts-expect-error a desktop method is not a native verb + const sent = pair.client.callNativeVerb('worktree.list', { a: 1 }) + await expect(sent).rejects.toThrow(/not a native verb/) + await pair.flush() + expect(pair.rpc.requests).toEqual([]) + }) +}) + +/** + * Every refusal reaches the caller under one type, carrying the shell's own code. + * + * Without this a caller had to read message text to tell an out-of-scope mime from a clipboard too + * large to send, and those are different things to do something about. + */ +describe('a verb the shell refuses', () => { + async function rejectionFrom(serveNativeVerb: () => Promise): Promise { + const pair = createFakeBridgePortPair({ serveNativeVerb }) + const verbs = await mount(pair) + const read = verbs.readClipboardText().catch((error: unknown) => error) + await pair.flush() + const caught = await read + if (!(caught instanceof NativeVerbError)) { + throw new Error(`expected a NativeVerbError, got ${String(caught)}`) + } + return caught + } + + it('names an out-of-scope mime as its own reason, without the handler message', async () => { + // A coded error, not the handler's class: what the host reads is the `code` property, so this + // is the contract between the two and importing the real class would pull react-native in. + const declined = Object.assign(new Error('image is not served by this build'), { + code: 'native_verb_out_of_scope' + }) + const error = await rejectionFrom(() => Promise.reject(declined)) + expect(error.reason).toBe('native_verb_out_of_scope') + // The handler's words stay on the device: this one names the mime, and a read that failed + // after reading could name what it read. + expect(error.message).not.toContain('image') + }) + + it('separates a device failure from an out-of-scope one, which is the point of the codes', async () => { + const error = await rejectionFrom(() => Promise.reject(new Error('the pasteboard is gone'))) + expect(error.reason).toBe('native_verb_failed') + expect(error.message).not.toContain('pasteboard') + }) + + it('answers a reason from the declared list for every arm a caller can reach', async () => { + const reasons = [ + (await rejectionFrom(() => Promise.reject(new Error('x')))).reason, + (await rejectionFrom(() => Promise.resolve({ value: 'a'.repeat(9 * 1024 * 1024) }))).reason, + (await rejectionFrom(() => Promise.resolve({ nonsense: 1 }))).reason + ] + // No `unreported`: every path this build can take names itself. + for (const reason of reasons) { + expect(NATIVE_VERB_REASONS, reason).toContain(reason) + expect(reason).not.toBe('unreported') + } + expect(reasons).toEqual(['native_verb_failed', 'reply-too-large', 'native_verb_result']) + }) + + it('names the frame refusal when the reply could never have reached the page', async () => { + const error = await rejectionFrom(() => Promise.resolve({ value: 'a'.repeat(9 * 1024 * 1024) })) + expect(error.reason).toBe('reply-too-large') + }) + + it('names the grant when this side refused before sending', async () => { + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: ['navigate'] } }) + act(() => { + create( + + + + ) + }) + const verbs = held.verbs + if (verbs === null) { + throw new Error('nothing mounted') + } + await expect(verbs.readClipboardText()).rejects.toMatchObject({ reason: 'ungranted' }) + }) +}) + +describe('a code this page has never heard of', () => { + it('floors to unreported rather than crossing verbatim', async () => { + // Delivered as a frame, not through the pair: this build's host normalises an unknown code to + // `native_verb_failed` before it leaves, so the only way to be a page reading a shell newer + // than itself is to be handed the frame such a shell would send. + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: [...BRIDGE_NATIVE_VERB_NAMES] } }) + act(() => { + create( + + + + ) + }) + const verbs = held.verbs + if (verbs === null) { + throw new Error('nothing mounted') + } + const read = verbs.readClipboardText().catch((error: unknown) => error) + const sent = page.frames().filter((frame) => frame.type === 'request') + const id = sent.at(-1)?.type === 'request' ? sent.at(-1)?.id : undefined + if (id === undefined) { + throw new Error('no request went out') + } + page.deliver({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'error', + id, + error: { + category: 'BridgeNativeVerbRefusedError', + code: 'native_verb_something_new', + message: 'a verb from a later build', + isRpcDeliveryUnknown: false + } + }) + const caught = await read + expect(caught).toBeInstanceOf(NativeVerbError) + expect(caught instanceof NativeVerbError && caught.reason).toBe('unreported') + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts new file mode 100644 index 00000000000..805dc9a7a49 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts @@ -0,0 +1,151 @@ +import { useMemo } from 'react' +import { z } from 'zod' +import { usePageBridgeClient } from '../../transport/client-context.web' +import { + clipboardReadResultSchema, + clipboardWriteResultSchema, + type BridgeClipboardMime, + type BridgeNativeVerb +} from './bridge-native-verbs' + +/** + * The page's side of the shell-answered verbs, typed from the same table the host serves. + * + * Every member goes out as an ordinary `request`, so it settles on the same frames and counts + * against the same in-flight cap as any other. What makes it a native verb is the method name: the + * host answers anything under the `native.` prefix itself and never forwards it. + * + * A verb the shell did not grant is refused before a frame is sent, because the answer is what the + * caller acts on: a promise that rejected after a round trip and one that never left look the same + * to an `await`, but only the first costs a slot. + * + * Results are parsed rather than trusted. The shell is not hostile, but it is a different build + * than the page, and a verb whose result shape moved should fail here rather than halfway through + * a screen that read a field which is not there. + */ +export type NativeVerbs = { + /** Whether this shell serves the verbs at all; false leaves a caller its own fallback. */ + granted: boolean + writeClipboardText: (value: string) => Promise + readClipboardText: () => Promise +} + +/** + * Every way a verb can fail, in one shape a caller can switch on. + * + * `reason` is always a member of `NATIVE_VERB_REASONS`: one per fault the seam names, plus + * `ungranted` which this side decides before a frame is sent, plus the frame refusals such as + * `reply-too-large`. A code from a shell newer than this page floors to `unreported` rather than + * crossing verbatim, so a `switch` over the list stays exhaustive. + * + * The message is not a contract. It is the shell's words where it had any, which say which verb + * and roughly why; a handler's own words never cross, so nothing may be read out of it. + */ +export class NativeVerbError extends Error { + readonly reason: NativeVerbReason + + constructor(reason: NativeVerbReason, message: string) { + super(message) + this.name = 'NativeVerbError' + this.reason = reason + } +} + +/** + * The shell's own code, which `reconstructBridgeError` copies onto the rejection it builds. + * + * `code` is not a property of `Error`, so it is parsed into a named shape rather than reached for: + * the rejection is whatever crossed the bridge, and a schema says what this reads without + * asserting the rest of it away. + */ +const shellCodedErrorSchema = z.object({ code: z.string() }) + +/** + * Every reason a caller can be handed, so a `switch` over them is exhaustive. + * + * `ungranted` is this side's, decided before a frame is sent. The rest are the shell's, carried on + * the rejection by `reconstructBridgeError`. `unreported` is the floor: nothing in this build + * reaches it, and it exists so a shell newer than the page still produces a reason rather than a + * blank one. + */ +export const NATIVE_VERB_REASONS = [ + 'ungranted', + 'native_verb_unknown', + 'native_verb_ungranted', + 'native_verb_params', + 'native_verb_result', + 'native_verb_out_of_scope', + 'native_verb_failed', + 'native_verb_not_a_stream', + 'native_verb_not_a_verb', + 'bridge_cap_exceeded', + 'bridge_host_disposed', + 'reply-too-large', + 'unreported' +] as const + +export type NativeVerbReason = (typeof NATIVE_VERB_REASONS)[number] + +const reasonSchema = z.enum(NATIVE_VERB_REASONS) + +/** + * Floored, not passed through: a shell newer than this page can name a code this build has never + * heard of, and handing it to a caller switching over the list would fall off the end silently. + */ +function nativeVerbReason(error: unknown): NativeVerbReason { + const coded = shellCodedErrorSchema.safeParse(error) + if (!coded.success) { + return 'unreported' + } + const known = reasonSchema.safeParse(coded.data.code) + return known.success ? known.data : 'unreported' +} + +export function useNativeVerbs(): NativeVerbs { + const client = usePageBridgeClient() + + return useMemo(() => { + const has = (verb: string): boolean => + client.getShellSession()?.grants.native.includes(verb) === true + + /** + * One parse of the result, with the verb's own schema, inside the catch. + * + * The host validated the same shape before it answered; this is the page's own check that the + * shell it is talking to is the build it expects. Parsing again at a caller would sit outside + * this catch and escape as a bare `ZodError`, which is the one shape this surface promises not + * to throw. + */ + async function call( + verb: BridgeNativeVerb, + params: unknown, + result: z.ZodType + ): Promise { + if (!has(verb)) { + throw new NativeVerbError('ungranted', `this shell did not grant ${verb}`) + } + try { + // No `ok: false` arm: a refusal crosses as an `error` frame and rejects this await, and a + // `native.` method is never forwarded, so there is no host `RpcFailure` to carry back. + const reply = await client.callNativeVerb(verb, params) + return result.parse(reply.result) + } catch (error) { + throw error instanceof NativeVerbError + ? error + : new NativeVerbError( + nativeVerbReason(error), + error instanceof Error ? error.message : `${verb} failed` + ) + } + } + + const mime: BridgeClipboardMime = 'text' + return { + granted: has('native.clipboard.write') && has('native.clipboard.read'), + writeClipboardText: async (value) => + (await call('native.clipboard.write', { mime, value }, clipboardWriteResultSchema)).written, + readClipboardText: async () => + (await call('native.clipboard.read', { mime }, clipboardReadResultSchema)).value + } + }, [client]) +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx index 3ee31a7b560..55e438b69bc 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx @@ -6,6 +6,9 @@ type RouteDependencies = { storage: Map routes: { pathname: string; params?: Record }[] panels: { hostId: string; worktreeId: string; name?: string }[] + /** `mount:` / `unmount:`, which is the only thing that tells a remount from + * a prop update — and a remount is what drops the old session's bridge and its grants. */ + lifecycle: string[] params: Record } @@ -13,6 +16,7 @@ const dependencies = vi.hoisted((): RouteDependencies => ({ storage: new Map(), routes: [], panels: [], + lifecycle: [], params: {} })) @@ -53,15 +57,28 @@ vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({ } })) -vi.mock('./MobileWebShellScreen', () => ({ - MobileWebShellScreen: (props: { - hostId: string - route: { pathname: string; params?: Record } - }) => { - dependencies.routes.push(props.route) - return null +vi.mock('./MobileWebShellScreen', async () => { + const React = await import('react') + return { + MobileWebShellScreen: (props: { + hostId: string + route: { pathname: string; params?: Record } + }) => { + dependencies.routes.push(props.route) + // Empty deps on purpose: keyed on the pathname this would re-fire on a prop update and read + // exactly like a remount, which is the one thing it exists to tell apart. + const mountedAs = React.useRef(props.route.pathname) + React.useEffect(() => { + const pathname = mountedAs.current + dependencies.lifecycle.push(`mount:${pathname}`) + return () => { + dependencies.lifecycle.push(`unmount:${pathname}`) + } + }, []) + return null + } } -})) +}) import { BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge/bridge-caps' import MobileAgentSessionHistoryScreen from '../../app/h/[hostId]/agent-history/[worktreeId]' @@ -77,6 +94,7 @@ describe('the native agent-history route that hands off to the shell', () => { dependencies.storage.clear() dependencies.routes.length = 0 dependencies.panels.length = 0 + dependencies.lifecycle.length = 0 dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' } Object.assign(globalThis, { __DEV__: true }) dependencies.storage.set('orca:mobileWebShellEnabled', 'true') @@ -172,4 +190,37 @@ describe('the native agent-history route that hands off to the shell', () => { expect(dependencies.routes).toEqual([]) expect(dependencies.panels.at(-1)).toEqual({ hostId: 'host-1', worktreeId: '', name: '' }) }) + + /** + * A route change is a new session, and the old one's bridge must not outlive it. + * + * The host captures the grants its session was opened with, so a screen reused across a route + * change keeps authorising frames under the grants of the route the page has left. Only a remount + * drops it, and only a key guarantees one. + */ + describe('changing the route this screen stands for', () => { + it('remounts the shell, so the bridge opened for the old route is disposed', async () => { + const rendered: { tree: ReturnType | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileAgentSessionHistoryScreen)) + }) + // The flag read is async, so the shell is not on screen until it settles; the other cases here + // flush it the same way. + await act(async () => { + await Promise.resolve() + }) + expect(dependencies.lifecycle).toEqual(['mount:/h/host-1/agent-history/wt-1']) + dependencies.params = { hostId: 'host-1', worktreeId: 'wt-2', name: 'another worktree' } + await act(async () => { + rendered.tree?.update(createElement(MobileAgentSessionHistoryScreen)) + }) + // Unmount before mount: the old bridge is gone before the new session exists, rather than + // being updated in place with the new route's props. + expect(dependencies.lifecycle).toEqual([ + 'mount:/h/host-1/agent-history/wt-1', + 'unmount:/h/host-1/agent-history/wt-1', + 'mount:/h/host-1/agent-history/wt-2' + ]) + }) + }) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts index 2c61ec55998..a12d1d107f4 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -186,6 +186,9 @@ export type MobileWebShellSession = { /** Every route pattern this shell would render from the page, as the bundle in hand declares * them. The page is told, so it keeps a navigation into one of them instead of handing it back. */ readonly pageRoutes: readonly string[] + /** What the route this mount stands for declared, narrowed to what this shell implements. It is + * what `init` grants, so a route that asked for less is served less. */ + readonly routeGrants: readonly string[] readonly state: MobileWebShellSessionState readonly retriedOnce: boolean readonly remountedOnce: boolean diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts new file mode 100644 index 00000000000..6abec262c2d --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts @@ -0,0 +1,138 @@ +import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' +import { + isMobileWebBundleTransportFailure, + mobileWebBundleManifestRead +} from '../transport/mobile-web-bundle-operations' +import { runRpcOperation } from '../transport/rpc-operation' +import type { RpcClient } from '../transport/rpc-client' +import type { GenerationStore } from './generation-store' +import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' +import { generationDirectoryPath } from './generation-store-file-system' +import type { + CachedGeneration, + MobileWebShellReadFailure, + MobileWebShellSessionEvent +} from './mobile-web-shell-session-contract' + +/** + * The work the session's effects do: read the cache, read the manifest, download a generation. + * + * Split from the hook because these are the only parts that touch the network and the disk, and + * the hook above them is a reducer and a set of callbacks. Each one answers by sending an event + * back; none of them decides anything. + */ +export async function openCache( + store: GenerationStore, + hostKey: string +): Promise { + try { + // Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build + // never sweeps a cache it never wrote. + await store.sweepStagedGenerations() + const active = await store.readActiveGeneration(hostKey) + return active === null + ? null + : { + buildId: active.buildId, + directory: generationDirectoryPath(active.directory), + totalBytes: active.manifest.totalBytes, + routes: active.manifest.routes + } + } catch { + // A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow + // treats it as absent, which downloads when connected and says so when not. + return null + } +} + +/** A rejection the link caused says nothing about the bundle, and the reducer opens the cache on it + * rather than telling a phone that already holds a workspace it could not be downloaded. */ +function readFailure(error: unknown): MobileWebShellReadFailure { + return isMobileWebBundleTransportFailure(error) ? 'transport' : 'bundle' +} + +export async function readManifest( + client: RpcClient | null, + flow: number, + send: (event: MobileWebShellSessionEvent) => void +): Promise { + if (client === null) { + // No client is no link, and the gates are about to say so. + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + try { + const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null) + const manifest = opened.manifest + send({ + type: 'manifest-read', + flow, + manifest: { + buildId: manifest.buildId, + schemaVersion: manifest.schemaVersion, + runtimeProtocolVersion: manifest.runtimeProtocolVersion, + minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion, + totalBytes: manifest.totalBytes, + totalAssets: manifest.assets.length, + routes: manifest.routes + } + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } +} + +export async function download(args: { + client: RpcClient | null + store: GenerationStore + hostKey: string + flow: number + runtime: MobileWebShellRuntime + startedAt: number + downloads: Set + send: (event: MobileWebShellSessionEvent) => void +}): Promise { + const { client, store, hostKey, flow, runtime, send } = args + if (client === null) { + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + const controller = new AbortController() + args.downloads.add(controller) + try { + const fetched = await fetchMobileWebBundle({ + client, + signal: controller.signal, + onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress }) + }) + // The bytes are in; the session they were for may not be. The fetch throws on an abort it sees, + // but an abort landing between its last read and this line would otherwise still write a + // generation for a host screen nobody is on any more. + if (controller.signal.aborted) { + return + } + send({ type: 'download-staged', flow }) + const staged = await store.stageGeneration(hostKey, fetched) + // Again before the commit, because the commit is the write that is not the staging tree's to + // undo: it renames into the active slot and moves the host index. An abort that landed while + // the bytes were being staged takes the staged tree back out instead. + if (controller.signal.aborted) { + await store.abortStagedGeneration(staged).catch(() => undefined) + return + } + const committed = await store.commitGeneration(staged) + send({ + type: 'activated', + flow, + generationDirectory: generationDirectoryPath(committed.directory), + sessionId: runtime.mintSessionId(), + buildId: committed.buildId, + totalBytes: committed.manifest.totalBytes, + elapsedMs: runtime.now() - args.startedAt + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } finally { + args.downloads.delete(controller) + } +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-grants.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-grants.test.ts new file mode 100644 index 00000000000..5bb55257d57 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-grants.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { + CachedGeneration, + MobileWebShellManifestFacts +} from './mobile-web-shell-session-contract' +import { + CACHED, + MANIFEST, + PAGE_ROUTES, + afterCacheRead, + run +} from './mobile-web-shell-session-test-fixtures' + +/** + * A download that failed falls back to the generation on disk, and that generation's routes are + * what it must be judged by — including its grants. + * + * The newer manifest is read before the download is attempted, so without this the session keeps + * the newer bundle's grants and opens the older page under them: a cached route that never + * declared the clipboard would be granted it by a manifest it is not running. + */ +describe('falling back to the cached generation after a failed download', () => { + const cachedOnlyNavigate: CachedGeneration = { ...CACHED, routes: PAGE_ROUTES } + const manifestWithClipboard: MobileWebShellManifestFacts = { + ...MANIFEST, + routes: [{ pathname: '/h/[hostId]', grants: ['navigate', 'native.clipboard.read'] }] + } + + it('opens it under its own grants, not the ones the newer manifest declared', () => { + const step = run( + afterCacheRead(cachedOnlyNavigate).session, + { type: 'manifest-read', manifest: manifestWithClipboard }, + { type: 'download-failed', failure: 'transport' } + ) + expect(step.session.state.kind).toBe('activating') + expect([...step.session.routeGrants]).toEqual(['navigate']) + }) + + it('carries a verb declared in the manifest through to the session grants', () => { + // The whole path a verb takes before a page can call one: the desktop's manifest contract + // admits the name, the phone's reader keeps it, and the route policy grants it because this + // build implements it. + const step = run(afterCacheRead(null).session, { + type: 'manifest-read', + manifest: { + ...MANIFEST, + routes: [{ pathname: '/h/[hostId]', grants: ['navigate', 'native.clipboard.write'] }] + } + }) + expect([...step.session.routeGrants]).toEqual(['navigate', 'native.clipboard.write']) + }) + + it('had the newer grants before the download failed, so the case discriminates', () => { + const step = run(afterCacheRead(cachedOnlyNavigate).session, { + type: 'manifest-read', + manifest: manifestWithClipboard + }) + expect([...step.session.routeGrants]).toEqual(['navigate', 'native.clipboard.read']) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts new file mode 100644 index 00000000000..60873b8adae --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts @@ -0,0 +1,127 @@ +/** The reducer's fixtures and drivers, shared by the suites that are split by concern rather than + * by subject: one session, one set of gates, and the steps that get it to each state. */ +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +export { createMobileWebShellSession } from './mobile-web-shell-session' +import { + createMobileWebShellSession, + reduceMobileWebShellSession +} from './mobile-web-shell-session' +import type { + CachedGeneration, + MobileWebShellGates, + MobileWebShellManifestFacts, + MobileWebShellSession, + MobileWebShellSessionEvent, + MobileWebShellStep +} from './mobile-web-shell-session-contract' + +export function gates(overrides: Partial = {}): MobileWebShellGates { + return { + statusPending: false, + statusReadable: true, + reachability: 'connected', + hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], + hostStatus: { protocolVersion: 10, minCompatibleMobileVersion: 1 }, + ...overrides + } +} + +/** The route every session below is opened for, and the pattern the bundles list it under. */ +export const ROUTE = '/h/host-1' +export const PAGE_ROUTES = [{ pathname: '/h/[hostId]', grants: ['navigate'] }] + +export const MANIFEST: MobileWebShellManifestFacts = { + buildId: 'b'.repeat(64), + schemaVersion: 1, + runtimeProtocolVersion: 5, + minCompatibleRuntimeProtocolVersion: 2, + totalBytes: 4096, + totalAssets: 4, + routes: PAGE_ROUTES +} + +export const CACHED: CachedGeneration = { + buildId: MANIFEST.buildId, + directory: '/cache/mobile-web/host/generations/b', + totalBytes: 4096, + routes: PAGE_ROUTES +} + +/** An event as a test writes it. An effect result is stamped with the flow the session is on, which + * is what an in-order runner does; a test replaying a superseded run pins the flow itself. */ +type PendingEvent = E extends { flow: number } + ? Omit & { readonly flow?: number } + : E + +export function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent { + switch (event.type) { + case 'gates-changed': + case 'shell-failed': + case 'retry-pressed': + case 'document-loaded': + case 'page-ready': + return event + case 'cache-read': + case 'manifest-read': + case 'fetch-progress': + case 'download-staged': + case 'activated': + case 'remounted': + case 'download-failed': + case 'page-ready-deadline': + return { ...event, flow: event.flow ?? flow } + } +} + +export function run( + session: MobileWebShellSession, + ...events: readonly PendingEvent[] +): MobileWebShellStep { + let step: MobileWebShellStep = { session, effects: [] } + for (const event of events) { + step = reduceMobileWebShellSession(step.session, stamp(step.session.flow, event)) + } + return step +} + +export function started(overrides: Partial = {}): MobileWebShellStep { + return run(createMobileWebShellSession(ROUTE), { type: 'gates-changed', gates: gates(overrides) }) +} + +/** Connected, capability present, cache read, manifest in flight. */ +export function afterCacheRead(generation: CachedGeneration | null): MobileWebShellStep { + return run(started().session, { type: 'cache-read', generation }) +} + +export function readySession(): MobileWebShellStep { + return run( + afterCacheRead(CACHED).session, + { type: 'manifest-read', manifest: MANIFEST }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 12 + } + ) +} + +/** The second half of a recovery: the refetch the delete queued, through to a mounted view. */ +export function readyAgain(session: MobileWebShellSession, sessionId: string): MobileWebShellStep { + return run( + session, + { type: 'cache-read', generation: null }, + { type: 'manifest-read', manifest: MANIFEST }, + { type: 'download-staged' }, + { + type: 'activated', + generationDirectory: '/cache/gen', + sessionId, + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 7 + } + ) +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts index f74433aef1f..ccd6ba226bc 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -1,128 +1,16 @@ import { describe, expect, it } from 'vitest' -import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' import { createMobileWebShellSession, - reduceMobileWebShellSession -} from './mobile-web-shell-session' -import type { - CachedGeneration, - MobileWebShellGates, - MobileWebShellManifestFacts, - MobileWebShellSession, - MobileWebShellSessionEvent, - MobileWebShellStep -} from './mobile-web-shell-session-contract' - -function gates(overrides: Partial = {}): MobileWebShellGates { - return { - statusPending: false, - statusReadable: true, - reachability: 'connected', - hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], - hostStatus: { protocolVersion: 10, minCompatibleMobileVersion: 1 }, - ...overrides - } -} - -/** The route every session below is opened for, and the pattern the bundles list it under. */ -const ROUTE = '/h/host-1' -const PAGE_ROUTES = [{ pathname: '/h/[hostId]', grants: ['navigate'] }] - -const MANIFEST: MobileWebShellManifestFacts = { - buildId: 'b'.repeat(64), - schemaVersion: 1, - runtimeProtocolVersion: 5, - minCompatibleRuntimeProtocolVersion: 2, - totalBytes: 4096, - totalAssets: 4, - routes: PAGE_ROUTES -} - -const CACHED: CachedGeneration = { - buildId: MANIFEST.buildId, - directory: '/cache/mobile-web/host/generations/b', - totalBytes: 4096, - routes: PAGE_ROUTES -} - -/** An event as a test writes it. An effect result is stamped with the flow the session is on, which - * is what an in-order runner does; a test replaying a superseded run pins the flow itself. */ -type PendingEvent = E extends { flow: number } - ? Omit & { readonly flow?: number } - : E - -function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent { - switch (event.type) { - case 'gates-changed': - case 'shell-failed': - case 'retry-pressed': - case 'document-loaded': - case 'page-ready': - return event - case 'cache-read': - case 'manifest-read': - case 'fetch-progress': - case 'download-staged': - case 'activated': - case 'remounted': - case 'download-failed': - case 'page-ready-deadline': - return { ...event, flow: event.flow ?? flow } - } -} - -function run( - session: MobileWebShellSession, - ...events: readonly PendingEvent[] -): MobileWebShellStep { - let step: MobileWebShellStep = { session, effects: [] } - for (const event of events) { - step = reduceMobileWebShellSession(step.session, stamp(step.session.flow, event)) - } - return step -} - -function started(overrides: Partial = {}): MobileWebShellStep { - return run(createMobileWebShellSession(ROUTE), { type: 'gates-changed', gates: gates(overrides) }) -} - -/** Connected, capability present, cache read, manifest in flight. */ -function afterCacheRead(generation: CachedGeneration | null): MobileWebShellStep { - return run(started().session, { type: 'cache-read', generation }) -} - -function readySession(): MobileWebShellStep { - return run( - afterCacheRead(CACHED).session, - { type: 'manifest-read', manifest: MANIFEST }, - { - type: 'activated', - generationDirectory: CACHED.directory, - sessionId: 'session-one', - buildId: MANIFEST.buildId, - totalBytes: MANIFEST.totalBytes, - elapsedMs: 12 - } - ) -} - -/** The second half of a recovery: the refetch the delete queued, through to a mounted view. */ -function readyAgain(session: MobileWebShellSession, sessionId: string): MobileWebShellStep { - return run( - session, - { type: 'cache-read', generation: null }, - { type: 'manifest-read', manifest: MANIFEST }, - { type: 'download-staged' }, - { - type: 'activated', - generationDirectory: '/cache/gen', - sessionId, - buildId: MANIFEST.buildId, - totalBytes: MANIFEST.totalBytes, - elapsedMs: 7 - } - ) -} + readyAgain, + CACHED, + MANIFEST, + ROUTE, + afterCacheRead, + gates, + readySession, + run, + started +} from './mobile-web-shell-session-test-fixtures' describe('the gates decide whether a step is taken at all', () => { it('waits while a connection is still being made', () => { diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index d62a15e5caf..06f6c931fb1 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -15,7 +15,7 @@ import type { MobileWebShellStep } from './mobile-web-shell-session-contract' import { awaitsGates, gateKey, gateVerdict } from './mobile-web-shell-gates' -import { implementedPageRoutes, matchesRoutePattern } from './page-route-policy' +import { grantsForRoute, implementedPageRoutes, matchesRoutePattern } from './page-route-policy' /** * The host's connection state as the three answers a step here needs. @@ -49,6 +49,7 @@ export function createMobileWebShellSession(routePathname: string): MobileWebShe return { routePathname, pageRoutes: [], + routeGrants: [], state: CHECKING, retriedOnce: false, remountedOnce: false, @@ -148,9 +149,10 @@ function onCacheRead( } // The cached bundle's own list, which is the only one an unreachable host can be judged by. const pageRoutes = implementedPageRoutes(generation.routes) + const routeGrants = grantsForRoute(generation.routes, session.routePathname) return rendersRoute(pageRoutes, session.routePathname) - ? openCached(session, generation, { cached: generation, pageRoutes }) - : step(session, { cached: generation, pageRoutes, state: NATIVE_ROUTE }) + ? openCached(session, generation, { cached: generation, pageRoutes, routeGrants }) + : step(session, { cached: generation, pageRoutes, routeGrants, state: NATIVE_ROUTE }) } return step(session, { cached: generation, state: CHECKING }, [{ kind: 'read-manifest' }]) } @@ -166,8 +168,9 @@ function onManifestRead( // Before the compat verdict, because a route that stays native has nothing to wall about: a // bundle this shell could not open is not a reason to refuse a screen it was never going to open. const pageRoutes = implementedPageRoutes(manifest.routes) + const routeGrants = grantsForRoute(manifest.routes, session.routePathname) if (!rendersRoute(pageRoutes, session.routePathname)) { - return step(session, { pageRoutes, state: NATIVE_ROUTE }) + return step(session, { pageRoutes, routeGrants, state: NATIVE_ROUTE }) } const verdict = evaluateMobileWebBundleCompat({ hostCapabilities: gates.hostCapabilities, @@ -179,12 +182,13 @@ function onManifestRead( } const cached = session.cached if (cached !== null && cached.buildId === manifest.buildId) { - return openCached(session, cached, { pageRoutes }) + return openCached(session, cached, { pageRoutes, routeGrants }) } return step( session, { pageRoutes, + routeGrants, state: { kind: 'fetching', completedAssets: 0, @@ -249,7 +253,17 @@ function onDownloadFailed( // The link went, not the bundle. A generation already on disk was compatible when it was // written, and it is the same one the offline gate would have opened had the reachability // change arrived before this rejection did; which of the two lands first is a race. - return openCached(session, cached) + // + // Judged by its own routes, not the manifest's. The newer manifest was read before the + // download was attempted, so its `pageRoutes` and `routeGrants` are already on the session: + // opening the cached page under them would grant it what a bundle it is not running declared, + // and would mount it for a route only the newer bundle claims. + const pageRoutes = implementedPageRoutes(cached.routes) + const routeGrants = grantsForRoute(cached.routes, session.routePathname) + if (!rendersRoute(pageRoutes, session.routePathname)) { + return step(session, { pageRoutes, routeGrants, state: NATIVE_ROUTE }) + } + return openCached(session, cached, { pageRoutes, routeGrants }) } return step(session, { state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce } diff --git a/mobile/src/mobile-web-shell/page-route-policy.test.ts b/mobile/src/mobile-web-shell/page-route-policy.test.ts index 513ac572604..0f1c98d5911 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.test.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.test.ts @@ -3,8 +3,14 @@ import { implementedPageRoutes, matchesRoutePattern, pageRendersRoute, - MOBILE_WEB_SHELL_GRANTS + MOBILE_WEB_SHELL_GRANTS, + grantsForRoute } from './page-route-policy' +import { + BRIDGE_NATIVE_METHOD_PREFIX, + BRIDGE_NATIVE_VERB_NAMES, + BRIDGE_NATIVE_VERBS +} from './bridge/bridge-native-verbs' describe('matching a concrete route against a pattern', () => { it('matches a dynamic segment against one segment and never against a path', () => { @@ -69,6 +75,80 @@ describe('the grants this app implements', () => { it('names exactly what the shell honours over the bridge', () => { // The same list `init.grants.native` gives the page. A name here with nothing behind it is a // route the desktop will hand over and the page will find it cannot use. - expect([...MOBILE_WEB_SHELL_GRANTS]).toEqual(['navigate', 'storage', 'externalLink']) + expect([...MOBILE_WEB_SHELL_GRANTS]).toEqual([ + 'navigate', + 'storage', + 'externalLink', + 'native.clipboard.write', + 'native.clipboard.read' + ]) + }) +}) + +/** + * A verb cannot be advertised without a handler, or handled without being advertised. + * + * The table is keyed on the same tuple this list spreads, so a missing row does not compile. This + * is the other direction: a name reaching `init.grants.native` that the table has never heard of, + * which a page would then be told it may call. + */ +describe('the native verbs this app serves', () => { + it('advertises exactly the verbs the table holds', () => { + const advertised = MOBILE_WEB_SHELL_GRANTS.filter((grant) => + grant.startsWith(BRIDGE_NATIVE_METHOD_PREFIX) + ) + expect([...advertised].sort()).toEqual([...BRIDGE_NATIVE_VERB_NAMES].sort()) + expect(Object.keys(BRIDGE_NATIVE_VERBS).sort()).toEqual([...BRIDGE_NATIVE_VERB_NAMES].sort()) + }) + + it('names them so a route can declare one, which is what keeps that route native without it', () => { + // A bundle listing a route that needs the clipboard, against a shell too old to serve it. + expect( + implementedPageRoutes([ + { pathname: '/h/[hostId]/tasks', grants: ['navigate', 'native.clipboard.write'] } + ]) + ).toEqual(['/h/[hostId]/tasks']) + expect( + implementedPageRoutes([ + { pathname: '/h/[hostId]/tasks', grants: ['navigate', 'native.dictation.start'] } + ]) + ).toEqual([]) + }) +}) + +/** + * What an old phone does with a grant name it has never heard of. + * + * Widening what a manifest field may contain is a new optional value crossing to readers that + * shipped before it. The phone's manifest schema bounds a grant's length and nothing else, on + * purpose, so an unknown name is not a parse failure that would refuse the whole bundle — it is a + * grant this build does not implement, and the route carrying it stays native. + */ +describe('a grant name this build has never heard of', () => { + it('leaves that route native rather than refusing the bundle', () => { + expect( + implementedPageRoutes([ + { pathname: '/h/[hostId]', grants: ['navigate'] }, + { pathname: '/h/[hostId]/tasks', grants: ['navigate', 'native.dictation.start'] } + ]) + ).toEqual(['/h/[hostId]']) + }) + + it('grants nothing from it either, so a route it names is served none of it', () => { + expect( + grantsForRoute( + [{ pathname: '/h/[hostId]', grants: ['navigate', 'native.dictation.start'] }], + '/h/host-1' + ) + ).toEqual(['navigate']) + }) + + it('carries a verb the build does implement all the way to the session grants', () => { + expect( + grantsForRoute( + [{ pathname: '/h/[hostId]/tasks', grants: ['navigate', 'native.clipboard.write'] }], + '/h/host-1/tasks' + ) + ).toEqual(['navigate', 'native.clipboard.write']) }) }) diff --git a/mobile/src/mobile-web-shell/page-route-policy.ts b/mobile/src/mobile-web-shell/page-route-policy.ts index 81458353141..ae73ebd1c7d 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.ts @@ -1,4 +1,5 @@ import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas' +import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs' /** The manifest's route entries, as this shell reads them. */ export type MobileWebPageRoute = NonNullable[number] @@ -11,7 +12,14 @@ export type MobileWebPageRoute = NonNullable matchesRoutePattern(pathname, pattern)) } + +/** + * The grants one page session gets: what this shell implements, narrowed to what the route it was + * opened for declared. + * + * Narrowed, because `init.grants.native` is what the page is allowed to do, and handing every + * session the shell's whole capability set gives a route that asked for `navigate` and `storage` + * the clipboard as well. That was harmless while every grant was a navigation or a write the page + * could make anyway, and stopped being harmless the moment a verb reads something back. + * + * A route the bundle does not declare gets nothing, which is the same answer as a page the shell + * would not render at all. + */ +export function grantsForRoute( + routes: readonly MobileWebPageRoute[] | undefined, + pathname: string +): string[] { + const declared = (routes ?? []).find((route) => matchesRoutePattern(pathname, route.pathname)) + return declared === undefined ? [] : declared.grants.filter(implementsGrant) +} diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts index 56447f622f7..2888be1a565 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts @@ -2,6 +2,7 @@ import { createElement, useImperativeHandle, useLayoutEffect, type ReactElement import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import type { OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src' +import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs' import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY, @@ -86,7 +87,9 @@ function FakeShellView(props: { * still has passive work queued, and it is the only window this suite can address. */ function DeliverDuringCommit(props: { - deliver: string | null + /** Delivered in order from the parent's layout effect, so a session can be opened and used in + * one commit — which is what a native batch carrying both frames looks like. */ + deliver: readonly string[] posted: PostedFrame[] probe: Probe faults: BridgeErrorCapture[] @@ -94,8 +97,8 @@ function DeliverDuringCommit(props: { }): ReactElement { const { deliver, probe } = props useLayoutEffect(() => { - if (deliver !== null) { - probe.view?.onBridgeMessage({ nativeEvent: { json: deliver } }) + for (const json of deliver) { + probe.view?.onBridgeMessage({ nativeEvent: { json } }) } }, [deliver, probe]) return createElement(Harness, { @@ -120,8 +123,10 @@ function Harness(props: { // Built inline on every render, as a caller writes it: the host is not rebuilt for it. route: { pathname: '/h/host-1' }, pageRoutes: ['/h/[hostId]'], + routeGrants: ['navigate', 'storage', 'externalLink', ...BRIDGE_NATIVE_VERB_NAMES], onNavigate: (href) => props.probe.navigations.push(href), onExternalLink: (url) => props.probe.externalLinks.push(url), + serveNativeVerb: () => Promise.resolve({ value: 'pasteboard' }), onNavigateBack: () => { props.probe.backPops += 1 return 'popped' @@ -292,6 +297,7 @@ describe('the bridge channel', () => { it('does not rebuild the host for a route object the caller built again', async () => { const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) // Same session, re-rendered: the harness passes a fresh `{ pathname }` every time. A rebuilt // host would have settled that request delivery-unknown on its way out. @@ -339,6 +345,7 @@ describe('the bridge channel', () => { it('forwards to the client the hook was given', async () => { const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) expect(fakeClient().requests.map((request) => request.method)).toEqual(['status.get']) }) @@ -373,30 +380,33 @@ describe('teardown', () => { it('disposes when the session leaves ready, and answers nothing after', async () => { const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver(clientFrame({ type: 'subscribe', id: ID, method: 'x.sub', params: {} })) await mounted.update({ kind: 'failed', reason: 'render-process-gone', retriedOnce: false }) expect(fakeClient().streams[0]?.unsubscribes).toBe(1) await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) await mounted.deliver(clientFrame({ type: 'ready' })) - expect(mounted.frames('session-one')).toEqual([]) + // The `init` the session opened with, and nothing after the host left ready. + expect(mounted.frames('session-one').filter((frame) => frame.type !== 'init')).toEqual([]) expect(fakeClient().requests).toEqual([]) }) it('disposes on unmount and settles what was in flight as delivery-unknown', async () => { const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' })) await act(async () => { mounted.tree.unmount() }) // The commit tears the host down while its own view is still attached, so the page hears why // its request will never answer instead of being left holding it. - expect(mounted.frames('session-one')).toEqual([ + expect(mounted.frames('session-one').filter((frame) => frame.type !== 'init')).toEqual([ expect.objectContaining({ type: 'error', id: ID }) ]) expect(warned).not.toHaveBeenCalled() fakeClient().requests[0]?.resolve(rpcSuccess('wire-1', 'ok')) await flushBridge() - expect(mounted.posted).toHaveLength(1) + expect(mounted.posted).toHaveLength(2) }) it('ignores a frame that arrives for a session the hook has moved past', async () => { @@ -497,6 +507,7 @@ describe('client changes', () => { it('rebuilds the host on a new client, so nothing crosses to the one that was replaced', async () => { const first = fakeClient() const mounted = await mount(readyState('session-one')) + await mounted.deliver(clientFrame({ type: 'ready' })) const next = createFakeRpcClient() doubles.client = next await mounted.update(readyState('session-one')) @@ -515,18 +526,24 @@ describe('client changes', () => { backPops: 0, storageWrites: [] } - const render = (deliver: string | null): ReactElement => + const render = (deliver: readonly string[]): ReactElement => createElement(DeliverDuringCommit, { deliver, posted, probe, faults: [], readies: [] }) const rendered: { tree: ReactTestRenderer | null } = { tree: null } await act(async () => { - rendered.tree = create(render(null)) + rendered.tree = create(render([])) }) const next = createFakeRpcClient() doubles.client = next // The session id does not change, so the handler's own fence does not apply: only handing the - // host over in the commit keeps this frame off the client that was replaced. + // host over in the commit keeps this frame off the client that was replaced. The `ready` rides + // with it because the rebuilt host has issued no `init` and serves no request before one. await act(async () => { - rendered.tree?.update(render(clientFrame({ type: 'request', id: ID, method: 'status.get' }))) + rendered.tree?.update( + render([ + clientFrame({ type: 'ready' }), + clientFrame({ type: 'request', id: ID, method: 'status.get' }) + ]) + ) }) expect(first.requests).toHaveLength(0) expect(next.requests).toHaveLength(1) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index b8314acb5ba..93138053b34 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -8,6 +8,7 @@ import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log' import type { BridgeInitRoute } from './bridge/bridge-envelope' import { createBridgeHost, type BridgeHost } from './bridge-host' import type { BridgeNavigateBackOutcome } from './bridge-host-contract' +import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import type { PageHostSnapshot } from './use-page-host-snapshot' @@ -60,10 +61,14 @@ export function useMobileWebShellBridge(args: { route: BridgeInitRoute /** The route patterns the page keeps for itself; everything else comes back as `navigate`. */ pageRoutes: readonly string[] + /** What this route declared, which is what `init` grants and what every grant check reads. */ + routeGrants: readonly string[] /** Opens a screen the page does not render, over the still-mounted view. */ onNavigate: (href: string) => void /** Opens a URL outside the app, on the page's behalf. */ onExternalLink: (url: string) => void + /** Serves one `native.` verb on this device, for a page that was granted it. */ + serveNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise /** Pops the stack this page was pushed onto, and says so when it did not. */ onNavigateBack: () => BridgeNavigateBackOutcome /** @@ -94,10 +99,14 @@ export function useMobileWebShellBridge(args: { // object in the deps would rebuild the host on every render and settle its pendings each time. const routeRef = useRef(args.route) const pageRoutesRef = useRef(args.pageRoutes) + const routeGrantsRef = useRef(args.routeGrants) + /** The session that has completed a handshake, so a host rebuilt for it inherits that. */ + const establishedSessionRef = useRef(null) // Read through a ref for the same reason: the host is built once per session, and a caller's // fresh closure every render must not tear one down and settle its pendings. const navigateRef = useRef(args.onNavigate) const externalLinkRef = useRef(args.onExternalLink) + const nativeVerbRef = useRef(args.serveNativeVerb) const navigateBackRef = useRef(args.onNavigateBack) const storageWriteRef = useRef(args.onStorageWrite) const readStorageRef = useRef(args.readStorage) @@ -109,8 +118,10 @@ export function useMobileWebShellBridge(args: { useLayoutEffect(() => { routeRef.current = args.route pageRoutesRef.current = args.pageRoutes + routeGrantsRef.current = args.routeGrants navigateRef.current = args.onNavigate externalLinkRef.current = args.onExternalLink + nativeVerbRef.current = args.serveNativeVerb navigateBackRef.current = args.onNavigateBack storageWriteRef.current = args.onStorageWrite readStorageRef.current = args.readStorage @@ -119,6 +130,7 @@ export function useMobileWebShellBridge(args: { routeRefusedRef.current = args.onRouteRefused }, [ args.onExternalLink, + args.serveNativeVerb, args.onNavigate, args.onNavigateBack, args.onPageFault, @@ -127,6 +139,7 @@ export function useMobileWebShellBridge(args: { args.onStorageWrite, args.readStorage, args.pageRoutes, + args.routeGrants, args.route ]) const snapshot = args.snapshot @@ -143,10 +156,13 @@ export function useMobileWebShellBridge(args: { sessionId, route: routeRef.current, pageRoutes: pageRoutesRef.current, + routeGrants: routeGrantsRef.current, + sessionEstablished: establishedSessionRef.current === sessionId, onPageFault: (error) => { pageFaultRef.current(error) }, onPageReady: () => { + establishedSessionRef.current = sessionId pageReadyRef.current() }, onRouteRefused: (issue) => { @@ -159,6 +175,7 @@ export function useMobileWebShellBridge(args: { onExternalLink: (url) => { externalLinkRef.current(url) }, + serveNativeVerb: (verb, params) => nativeVerbRef.current(verb, params), host: snapshot.host, readStorage: () => readStorageRef.current(), onStorageWrite: (key, value) => { diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts index ff3ffcf3728..8c9e2296dec 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -2,16 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' import { useHostProtocolGates } from '../components/HostProtocolGate' import { useHostClient } from '../transport/client-context' -import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' -import { - isMobileWebBundleTransportFailure, - mobileWebBundleManifestRead -} from '../transport/mobile-web-bundle-operations' -import { runRpcOperation } from '../transport/rpc-operation' -import type { RpcClient } from '../transport/rpc-client' import type { GenerationStore } from './generation-store' -import { generationDirectoryPath } from './generation-store-file-system' import { deriveHostCacheKey } from './host-cache-key' +import { download, openCache, readManifest } from './mobile-web-shell-session-effects' import { createMobileWebShellRuntime, PAGE_READY_DEADLINE_MS, @@ -23,8 +16,6 @@ import { reduceMobileWebShellSession } from './mobile-web-shell-session' import type { - CachedGeneration, - MobileWebShellReadFailure, MobileWebShellSessionEffect, MobileWebShellSessionEvent, MobileWebShellSessionState @@ -34,6 +25,7 @@ export type MobileWebShellSessionView = { readonly state: MobileWebShellSessionState /** The route patterns this shell would render from the page, for the page to be told about. */ readonly pageRoutes: readonly string[] + readonly routeGrants: readonly string[] readonly retry: () => void /** B3's failure reasons, forwarded verbatim; the reducer owns what each one means. */ readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void @@ -234,125 +226,10 @@ export function useMobileWebShellSession(args: { return { state, pageRoutes: sessionRef.current.pageRoutes, + routeGrants: sessionRef.current.routeGrants, retry, reportShellFailure, reportDocumentLoaded, reportPageReady } } - -async function openCache( - store: GenerationStore, - hostKey: string -): Promise { - try { - // Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build - // never sweeps a cache it never wrote. - await store.sweepStagedGenerations() - const active = await store.readActiveGeneration(hostKey) - return active === null - ? null - : { - buildId: active.buildId, - directory: generationDirectoryPath(active.directory), - totalBytes: active.manifest.totalBytes, - routes: active.manifest.routes - } - } catch { - // A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow - // treats it as absent, which downloads when connected and says so when not. - return null - } -} - -/** A rejection the link caused says nothing about the bundle, and the reducer opens the cache on it - * rather than telling a phone that already holds a workspace it could not be downloaded. */ -function readFailure(error: unknown): MobileWebShellReadFailure { - return isMobileWebBundleTransportFailure(error) ? 'transport' : 'bundle' -} - -async function readManifest( - client: RpcClient | null, - flow: number, - send: (event: MobileWebShellSessionEvent) => void -): Promise { - if (client === null) { - // No client is no link, and the gates are about to say so. - send({ type: 'download-failed', flow, failure: 'transport' }) - return - } - try { - const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null) - const manifest = opened.manifest - send({ - type: 'manifest-read', - flow, - manifest: { - buildId: manifest.buildId, - schemaVersion: manifest.schemaVersion, - runtimeProtocolVersion: manifest.runtimeProtocolVersion, - minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion, - totalBytes: manifest.totalBytes, - totalAssets: manifest.assets.length, - routes: manifest.routes - } - }) - } catch (error) { - send({ type: 'download-failed', flow, failure: readFailure(error) }) - } -} - -async function download(args: { - client: RpcClient | null - store: GenerationStore - hostKey: string - flow: number - runtime: MobileWebShellRuntime - startedAt: number - downloads: Set - send: (event: MobileWebShellSessionEvent) => void -}): Promise { - const { client, store, hostKey, flow, runtime, send } = args - if (client === null) { - send({ type: 'download-failed', flow, failure: 'transport' }) - return - } - const controller = new AbortController() - args.downloads.add(controller) - try { - const fetched = await fetchMobileWebBundle({ - client, - signal: controller.signal, - onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress }) - }) - // The bytes are in; the session they were for may not be. The fetch throws on an abort it sees, - // but an abort landing between its last read and this line would otherwise still write a - // generation for a host screen nobody is on any more. - if (controller.signal.aborted) { - return - } - send({ type: 'download-staged', flow }) - const staged = await store.stageGeneration(hostKey, fetched) - // Again before the commit, because the commit is the write that is not the staging tree's to - // undo: it renames into the active slot and moves the host index. An abort that landed while - // the bytes were being staged takes the staged tree back out instead. - if (controller.signal.aborted) { - await store.abortStagedGeneration(staged).catch(() => undefined) - return - } - const committed = await store.commitGeneration(staged) - send({ - type: 'activated', - flow, - generationDirectory: generationDirectoryPath(committed.directory), - sessionId: runtime.mintSessionId(), - buildId: committed.buildId, - totalBytes: committed.manifest.totalBytes, - elapsedMs: runtime.now() - args.startedAt - }) - } catch (error) { - send({ type: 'download-failed', flow, failure: readFailure(error) }) - } finally { - args.downloads.delete(controller) - } -} diff --git a/mobile/src/platform/native-clipboard.test.ts b/mobile/src/platform/native-clipboard.test.ts new file mode 100644 index 00000000000..da75c41adb3 --- /dev/null +++ b/mobile/src/platform/native-clipboard.test.ts @@ -0,0 +1,58 @@ +/** The device half: what the shell actually does with a verb the host let through. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const clipboard = vi.hoisted(() => ({ + setStringAsync: vi.fn(() => Promise.resolve(true)), + getStringAsync: vi.fn(() => Promise.resolve('')) +})) + +vi.mock('expo-clipboard', () => clipboard) + +import { serveNativeClipboardVerb } from './native-clipboard' + +beforeEach(() => { + clipboard.setStringAsync.mockReset() + clipboard.setStringAsync.mockImplementation(() => Promise.resolve(true)) + clipboard.getStringAsync.mockReset() + clipboard.getStringAsync.mockImplementation(() => Promise.resolve('on the pasteboard')) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('serving a clipboard verb', () => { + it('writes text and answers whether the pasteboard took it', async () => { + await expect( + serveNativeClipboardVerb('native.clipboard.write', { mime: 'text', value: 'copied' }) + ).resolves.toEqual({ written: true }) + expect(clipboard.setStringAsync.mock.calls).toEqual([['copied']]) + }) + + it('carries a pasteboard refusal through rather than reporting success', async () => { + clipboard.setStringAsync.mockImplementation(() => Promise.resolve(false)) + await expect( + serveNativeClipboardVerb('native.clipboard.write', { mime: 'text', value: 'copied' }) + ).resolves.toEqual({ written: false }) + }) + + it('reads text off the pasteboard', async () => { + await expect( + serveNativeClipboardVerb('native.clipboard.read', { mime: 'text' }) + ).resolves.toEqual({ value: 'on the pasteboard' }) + }) + + it('refuses an image as out of scope here, not as something the platform cannot do', async () => { + // `expo-clipboard` implements getImageAsync and setImageAsync, so a reason blaming the platform + // would send whoever adds this looking for a gap that is not there. + for (const verb of ['native.clipboard.write', 'native.clipboard.read'] as const) { + const params = + verb === 'native.clipboard.write' ? { mime: 'image', value: 'x' } : { mime: 'image' } + await expect(serveNativeClipboardVerb(verb, params)).rejects.toThrow( + /is not served by this build/ + ) + } + expect(clipboard.setStringAsync).not.toHaveBeenCalled() + expect(clipboard.getStringAsync).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/platform/native-clipboard.ts b/mobile/src/platform/native-clipboard.ts new file mode 100644 index 00000000000..de937335d96 --- /dev/null +++ b/mobile/src/platform/native-clipboard.ts @@ -0,0 +1,49 @@ +import * as Clipboard from 'expo-clipboard' +import { + clipboardReadParamsSchema, + clipboardWriteParamsSchema, + type BridgeNativeVerb, + type BridgeClipboardMime +} from '../mobile-web-shell/bridge/bridge-native-verbs' + +/** + * The device side of the clipboard verbs, on the shell where `expo-clipboard` exists. + * + * Only text is served. `expo-clipboard` implements `getImageAsync` and `setImageAsync`, so the + * refusal says this build does not serve the verb rather than that the platform cannot: a reason + * claiming the latter would send whoever adds images looking for a platform gap that is not there. + */ +export class NativeVerbOutOfScopeError extends Error { + /** Read by the host so this reaches the page as its own reason, without its message. */ + readonly code = 'native_verb_out_of_scope' + + constructor(verb: BridgeNativeVerb, mime: BridgeClipboardMime) { + super(`${mime} is not served by this build for ${verb}`) + this.name = 'NativeVerbOutOfScopeError' + } +} + +/** + * Params are parsed here with the schema for the verb being served, rather than read off the host's + * parse: the host parses to decide whether to dispatch at all, and this one is the boundary that + * hands a value to a device API, so it holds a typed value without an assertion. + */ +export async function serveNativeClipboardVerb( + verb: BridgeNativeVerb, + params: unknown +): Promise { + if (verb === 'native.clipboard.write') { + const { mime, value } = clipboardWriteParamsSchema.parse(params) + refuseNonText(verb, mime) + return { written: await Clipboard.setStringAsync(value) } + } + const { mime } = clipboardReadParamsSchema.parse(params) + refuseNonText(verb, mime) + return { value: await Clipboard.getStringAsync() } +} + +function refuseNonText(verb: BridgeNativeVerb, mime: BridgeClipboardMime): void { + if (mime !== 'text') { + throw new NativeVerbOutOfScopeError(verb, mime) + } +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 5cc18e44440..bb853ba260f 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -20,19 +20,26 @@ export type RpcRequest = { params?: unknown } +/** + * `_meta` is optional because the wire does not guarantee it. `isRpcResponse`, which is what both + * sides of the bridge actually read a reply through, checks `id`, `ok` and the presence of + * `result` or `error` and never looks at `_meta`; `src/shared/runtime-rpc-envelope.ts` already + * makes it optional on a failure. The shell also answers `native.` verbs itself, and those replies + * name no runtime because none produced them. Nothing in this app reads the field. + */ export type RpcSuccess = { id: string ok: true result: unknown streaming?: true - _meta: { runtimeId: string } + _meta?: { runtimeId: string } } export type RpcFailure = { id: string ok: false error: { code: string; message: string; data?: unknown } - _meta: { runtimeId: string } + _meta?: { runtimeId: string } } export type RpcResponse = RpcSuccess | RpcFailure diff --git a/src/shared/mobile-web-bundle/manifest-contract.test.ts b/src/shared/mobile-web-bundle/manifest-contract.test.ts index 00418f33b19..1397798f42e 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.test.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.test.ts @@ -128,8 +128,26 @@ describe('the page routes a manifest declares', () => { }) it('refuses a grant name that is not one', () => { + // One segment under `native` is not a verb: the namespace is `native..`, and + // anything shorter is a plain name wearing a dot. expect(withRoutes([{ pathname: '/h', grants: ['native.navigate'] }])).toBe(false) expect(withRoutes([{ pathname: '/h', grants: [''] }])).toBe(false) + for (const grant of ['navigate.', '.native', 'native..read', 'Native.Clipboard.Read', 'a.b']) { + expect(withRoutes([{ pathname: '/h', grants: [grant] }]), grant).toBe(false) + } + }) + + it('takes a native verb, which a route must be able to declare to ever be granted one', () => { + // Without this no manifest can name a verb, and with per-route grants that leaves every native + // verb unreachable for every route. + for (const grant of [ + 'native.clipboard.write', + 'native.clipboard.read', + 'native.file.pick', + 'native.a.b.c' + ]) { + expect(withRoutes([{ pathname: '/h', grants: [grant] }]), grant).toBe(true) + } }) it('refuses a route carrying a field the contract does not declare', () => { diff --git a/src/shared/mobile-web-bundle/manifest-contract.ts b/src/shared/mobile-web-bundle/manifest-contract.ts index b68b0d4df6b..b0a554b8dd8 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.ts @@ -28,7 +28,15 @@ const MAX_ROUTE_PATHNAME_LENGTH = 255 const MAX_GRANT_NAME_LENGTH = 64 /** Rooted, single-slash, no query and no fragment: a phone writes this into its own history. */ const ROUTE_PATHNAME_PATTERN = /^\/(?![/\\])[^?#\s]*$/ -const GRANT_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/ +/** + * A grant is either a plain capability name (`navigate`, `storage`, `externalLink`) or one of the + * shell-answered verbs, which live under `native.` and are named `native..`. + * + * Two segments at least, so a plain name wearing a dot is still refused: the verb namespace is what + * the shell's table declares, and a route that could not name one would never be granted one — + * which, with grants scoped per route, leaves every verb unreachable. + */ +const GRANT_NAME_PATTERN = /^(?:[a-zA-Z][a-zA-Z0-9]*|native(?:\.[a-z][a-z0-9]*){2,})$/ /** Every segment must be a name the bundle root can hold on all three desktop platforms: no * traversal, and none of the Windows shapes that cannot be created or that resolve to a device.