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.