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.