diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index e606d1253e3..2f41ab4629b 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -238,19 +238,21 @@ is the only compat gate the bridge has. Additive operations stay on the same version and negotiate through `init.grants`. A breaking envelope or security semantic requires a new native bridge version. -The shell and the page ship from different releases, so what a change costs -depends on its direction and on whether it adds a field or an operation: +The page parses every shell-authored payload with the plain contract schema, in +both directions and at every level. There is no forgiving rewrite: an +undeclared key, an unclassifiable array member, or an unknown value for a closed +set fails the frame with `invalid_message` and `retryable: false`. The protocol +version is what gates a change, so a field the page cannot name is a bug in the +shell/page/desktop release set rather than skew to absorb. The desktop reshapes +transcripts to the page contract before they leave the host; see +`mobile-web-native-chat-read-budget.ts`. -- **Additive field, shell to page** (any result or event payload) is always - safe and needs no negotiation. The page parses shell-authored payloads - through `tolerantMobileWebShellPayload`, which strips unknown keys, drops an - array member it cannot classify, and reads an unknown value for an - optional/nullable closed set as absent. Adding an enum value, a session tab - kind, or an optional field is therefore a degrade, not a break. Do not - reintroduce `.strict()` on that path: a page parse failure is - `invalid_message` with `retryable: false`, nothing re-subscribes, and the - one-shot fallback shares the schema, so both legs die on the same byte. - `shell-payload-tolerance-census.test.ts` fails if a strict node survives. +What a change costs depends on its direction and on whether it adds a field or +an operation: + +- **Additive field, shell to page** (any result or event payload) needs the + page contract to declare it in the same release. An undeclared key fails the + frame. - **Additive field, page to shell** in a native or legacy payload is a break. Native-capability and legacy request schemas stay `.strict()`; a newer page that sends a field an older shell does not know gets `invalid_request`. There @@ -264,17 +266,15 @@ depends on its direction and on whether it adds a field or an operation: the call site instead of hanging. - **Additive frame type, either direction** is safe at the same version: both receivers drop a frame they cannot parse. -- **Additive envelope field, shell to page** is safe for the same reason as a - payload field: `parseMobileWebBridgeShellMessage` and - `parseMobileWebBridgeInitialMessage` parse through the tolerant view, so an - undeclared key is stripped rather than dropping the frame. That matters most - for `init`, where dropping the frame costs the page every grant at once. - Stripping keeps the leak fence intact — an undeclared `resumeRoute.hostPath` - or a raw error `message` still never reaches the page. The page->shell - envelope stays strict. -- **Additive route kind or other closed variant** is not covered by any of the - above and must negotiate. An unknown `resumeRoute.kind` still fails `init`, - because the page cannot invent a meaning for a variant it does not have. +- **Additive envelope field, shell to page** must be declared before it is + sent. `parseMobileWebBridgeShellMessage` and + `parseMobileWebBridgeInitialMessage` drop a frame carrying a key the page does + not declare, which for `init` costs the page every grant at once. Failing + closed also keeps the leak fence: an undeclared `resumeRoute.hostPath` or a + raw error `message` never reaches the page. +- **Additive route kind or other closed variant** must negotiate. An unknown + `resumeRoute.kind` fails `init`, because the page cannot invent a meaning for + a variant it does not have. Desktop must retain support for the existing bridge floor until a replacement has shipped in at least two stable mobile releases and the supported shell diff --git a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts index a58ce9558f6..2d9afceacf8 100644 --- a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts @@ -13,7 +13,6 @@ import { MobileWebBridgeSubscriptionClient } from '../../../src/mobile-web/src/m import type { MobileWebBridgeSubscriptionSetup } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-setup' import { MobileWebOneShotRequestClient } from '../../../src/mobile-web/src/mobile-web-one-shot-request-client' import { MobileWebHostResultSchema } from '../../../src/shared/mobile-web/host-rpc-contract' -import { tolerantMobileWebShellPayload } from '../../../src/shared/mobile-web/shell-payload-tolerance' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' const CONTEXT = { @@ -107,10 +106,8 @@ describe('mobile web shell response schema corpus', () => { }) }) -/** What the page actually applies to a shell payload, so "cannot parse" here is the page's verdict - * rather than the authoring schema's. */ function pageRejects(schema: ZodType, payload: unknown): boolean { - return !tolerantMobileWebShellPayload(schema).safeParse(payload).success + return !schema.safeParse(payload).success } function namedSchemas(suffix: 'ResultSchema' | 'EventSchema'): NamedSchema[] { diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.ts deleted file mode 100644 index 0625a7f7b96..00000000000 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS, - MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS, - MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS, - MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT, - MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS, - MOBILE_WEB_NATIVE_CHAT_READ_LIMIT, - MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS -} from '../../../../shared/mobile-web/native-chat-operation-contract' - -export const MOBILE_WEB_NATIVE_CHAT_TRUNCATION_MARKER = '\n… (truncated)' -export const MOBILE_WEB_NATIVE_CHAT_OMITTED_BLOCK = { - type: 'text', - text: MOBILE_WEB_NATIVE_CHAT_TRUNCATION_MARKER -} - -/** - * Clips host transcript content down to the page's wire bounds. - * - * The host sanitizer caps text blocks at 64 KiB and bounds neither block count nor identifier - * length, but the page's read schema is `.strict()` at 4200 characters, 64 blocks and a 1024 - * character id. The page parses through the tolerant rewrite, which turns each overrun into a - * different silent loss: an over-long text block is an unclassifiable member of an array of unions - * and disappears, while an over-long id or an over-count block array fails its message, and - * `messages` is not a union array, so one bad message fails the whole read with a non-retryable - * `invalid_message`. - * - * Unknown keys and unknown block types pass through untouched: the shell forwards this payload - * without parsing it, so a field a newer desktop and its own page both understand must survive an - * older shell in the middle. - */ -export function clipMobileWebNativeChatToPageContract(value: unknown): unknown { - if (!isRecord(value) || !Array.isArray(value.messages)) { - return value - } - return { - ...value, - messages: value.messages.slice(0, MOBILE_WEB_NATIVE_CHAT_READ_LIMIT).map(clipMessage) - } -} - -function clipMessage(value: unknown): unknown { - if (!isRecord(value)) { - return value - } - return { - ...value, - ...clippedIdentifier(value, 'id'), - ...clippedIdentifier(value, 'turnId'), - ...(Array.isArray(value.blocks) ? { blocks: clipBlocks(value.blocks) } : {}) - } -} - -/** Clipped rather than dropped: losing the message loses history the page cannot ask for again, - * and the clip is deterministic, so read and subscribe still agree on the dedup key. */ -function clippedIdentifier(message: Record, key: 'id' | 'turnId') { - const value = message[key] - return typeof value === 'string' && - value.length > MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS - ? { [key]: value.slice(0, MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS) } - : {} -} - -function clipBlocks(blocks: unknown[]): unknown[] { - const clipped = blocks.map(clipBlock) - return clipped.length <= MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT - ? clipped - : [ - ...clipped.slice(0, MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT - 1), - MOBILE_WEB_NATIVE_CHAT_OMITTED_BLOCK - ] -} - -function clipBlock(value: unknown): unknown { - if (!isRecord(value)) { - return value - } - if (value.type === 'text' || value.type === 'tool-result') { - const field = value.type === 'text' ? 'text' : 'output' - return { ...value, ...clippedProse(value, field) } - } - if (value.type === 'tool-call') { - return { ...value, ...clippedLabel(value, 'name') } - } - if (value.type !== 'image-ref') { - return value - } - return { - ...value, - ...droppedWhenOverLong(value, 'path', MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS), - ...droppedWhenOverLong(value, 'url', MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS), - ...droppedWhenOverLong(value, 'alt', MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS) - } -} - -/** Displayed content: the reader is told it was cut. */ -function clippedProse(block: Record, key: string) { - const value = block[key] - if ( - typeof value !== 'string' || - value.length <= MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS - ) { - return {} - } - const head = value.slice( - 0, - MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS - - MOBILE_WEB_NATIVE_CHAT_TRUNCATION_MARKER.length - ) - return { [key]: `${head}${MOBILE_WEB_NATIVE_CHAT_TRUNCATION_MARKER}` } -} - -/** A short label, so a marker inside it would read as part of the name. */ -function clippedLabel(block: Record, key: string) { - const value = block[key] - return typeof value === 'string' && value.length > MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS - ? { [key]: value.slice(0, MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS) } - : {} -} - -/** A clipped reference is a wrong reference the page would try to resolve; absent renders a - * placeholder instead. */ -function droppedWhenOverLong(block: Record, key: string, maximum: number) { - const value = block[key] - return typeof value === 'string' && value.length > maximum ? { [key]: undefined } : {} -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.test.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.test.ts similarity index 79% rename from src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.test.ts rename to src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.test.ts index 2e4083e7c94..d5e0df929ea 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat-page-contract-clip.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.test.ts @@ -7,15 +7,10 @@ import { MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS, MobileWebNativeChatReadResultSchema } from '../../../../shared/mobile-web/native-chat-operation-contract' -import { tolerantMobileWebShellPayload } from '../../../../shared/mobile-web/shell-payload-tolerance' import { boundMobileWebNativeChatRead } from './mobile-web-native-chat-read-budget' import { windowForClient } from './native-chat-rpc-message-sanitizer' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -// What the page really runs. The strict parse below is the stronger claim: nothing is left for the -// tolerant rewrite to rescue. -const pageContract = tolerantMobileWebShellPayload(MobileWebNativeChatReadResultSchema) - function message(id: string, blocks: unknown[]) { return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } } @@ -47,7 +42,6 @@ describe('native chat reads against the page contract', () => { 'turn-blocks', 'x'.repeat(MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS) ]) - expect(pageContract.safeParse(bounded)).toMatchObject({ success: true }) }) it('shows a clipped text block instead of dropping it', () => { @@ -106,25 +100,27 @@ describe('native chat reads against the page contract', () => { expect(MobileWebNativeChatReadResultSchema.safeParse(bounded)).toMatchObject({ success: true }) }) - it('leaves fields and block types the page contract has never named untouched', () => { + it('drops host-only block detail and block types the page cannot name', () => { const bounded = boundMobileWebNativeChatRead({ messages: [ - { - ...message('turn-1', [ - { type: 'text', text: 'a'.repeat(100_000), futureBlockField: 'kept' }, - { type: 'future-block', field: 'kept' } - ]), - future: { revision: 2 } - } + message('turn-1', [ + { + type: 'text', + text: 'hi', + providerFrame: { provider: 'claude', kind: 'raw', payload: {} } + }, + { type: 'tool-result', output: 'out', editPatch: { filePath: 'a.ts', hunks: [] } }, + { type: 'future-block', field: 'dropped' } + ]) ], - hasMore: false, - futureLifecycle: 'new' - }) as { futureLifecycle: string; messages: { future: unknown; blocks: unknown[] }[] } + hasMore: false + }) as { messages: { blocks: unknown[] }[] } - expect(bounded.futureLifecycle).toBe('new') - expect(bounded.messages[0].future).toEqual({ revision: 2 }) - expect(bounded.messages[0].blocks[0]).toMatchObject({ futureBlockField: 'kept' }) - expect(bounded.messages[0].blocks[1]).toEqual({ type: 'future-block', field: 'kept' }) + expect(bounded.messages[0].blocks).toEqual([ + { type: 'text', text: 'hi' }, + { type: 'tool-result', output: 'out' } + ]) + expect(MobileWebNativeChatReadResultSchema.safeParse(bounded)).toMatchObject({ success: true }) }) it('does not mutate the host transcript it was given', () => { diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.ts index ce2e1c97516..2ca6bf43f1c 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-read-budget.ts @@ -1,19 +1,43 @@ import { MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS, - MOBILE_WEB_NATIVE_CHAT_EVENT_MAX_BYTES + MOBILE_WEB_NATIVE_CHAT_EVENT_MAX_BYTES, + MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_READ_LIMIT, + MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS } from '../../../../shared/mobile-web/native-chat-operation-contract' -import { - clipMobileWebNativeChatToPageContract, - MOBILE_WEB_NATIVE_CHAT_OMITTED_BLOCK as omittedBlock, - MOBILE_WEB_NATIVE_CHAT_TRUNCATION_MARKER as MARKER -} from './mobile-web-native-chat-page-contract-clip' +const MARKER = '\n… (truncated)' +const TOOL_CALL_STATES = new Set(['running', 'completed', 'failed']) +// Every key the page's read result and stream event schemas declare, and nothing else. +const TRANSCRIPT_KEYS = [ + 'type', + 'messages', + 'hasMore', + 'beforeOffset', + 'error', + 'pending', + 'lifecycle' +] as const +const MESSAGE_KEYS = ['id', 'role', 'blocks', 'timestamp', 'source', 'turnId'] as const +const omittedBlock = { type: 'text', text: MARKER } const byteLength = (value: unknown): number => Buffer.byteLength(JSON.stringify(value)) +/** + * Reshapes a host transcript into what the page's read schema declares. + * + * The host sanitizer caps text blocks at 64 KiB, bounds neither block count nor identifier length, + * and carries host-only detail (`providerFrame`, `editPatch`) the page never names. The page parses + * the relayed payload with the plain strict schema, and a parse failure there is permanent: + * `invalid_message` is not retryable and nothing re-subscribes. So the host emits page-shaped + * blocks and page-sized content, then bounds the whole payload to the event budget. + */ export function boundMobileWebNativeChatRead(source: unknown): unknown { // Always first: the byte budget only engages above 512 KiB, and every page-contract overrun is // silent well under it. - const value = clipMobileWebNativeChatToPageContract(source) + const value = pageShapedTranscript(source) if (byteLength(value) <= MOBILE_WEB_NATIVE_CHAT_EVENT_MAX_BYTES) { return value } @@ -41,6 +65,117 @@ export function boundMobileWebNativeChatRead(source: unknown): unknown { } } +function pageShapedTranscript(value: unknown): unknown { + if (!isRecord(value) || !Array.isArray(value.messages)) { + return value + } + return { + ...declared(value, TRANSCRIPT_KEYS), + messages: value.messages.slice(0, MOBILE_WEB_NATIVE_CHAT_READ_LIMIT).map(pageShapedMessage) + } +} + +function declared(value: Record, keys: readonly string[]) { + const shaped: Record = {} + for (const key of keys) { + if (value[key] !== undefined) { + shaped[key] = value[key] + } + } + return shaped +} + +/** Ids are clipped rather than dropped: losing the message loses history the page cannot ask for + * again, and the clip is deterministic, so read and subscribe still agree on the dedup key. */ +function pageShapedMessage(value: unknown): unknown { + if (!isRecord(value)) { + return value + } + return { + ...declared(value, MESSAGE_KEYS), + ...clippedIdentifier(value, 'id'), + ...clippedIdentifier(value, 'turnId'), + ...(Array.isArray(value.blocks) ? { blocks: pageShapedBlocks(value.blocks) } : {}) + } +} + +function clippedIdentifier(message: Record, key: 'id' | 'turnId') { + const value = message[key] + return typeof value === 'string' && + value.length > MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS + ? { [key]: value.slice(0, MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS) } + : {} +} + +function pageShapedBlocks(blocks: unknown[]): unknown[] { + const shaped = blocks.map(pageShapedBlock).filter((block) => block !== null) + return shaped.length <= MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT + ? shaped + : [...shaped.slice(0, MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT - 1), omittedBlock] +} + +/** Each arm names every field the page declares, so host-only detail never reaches a strict parse. + * A block the page cannot name is dropped rather than failing the message around it. */ +function pageShapedBlock(value: unknown): unknown { + if (!isRecord(value)) { + return null + } + if (value.type === 'text') { + return typeof value.text === 'string' ? { type: 'text', text: clippedProse(value.text) } : null + } + if (value.type === 'tool-result') { + return typeof value.output === 'string' + ? { + type: 'tool-result', + output: clippedProse(value.output), + ...(value.isError === undefined ? {} : { isError: value.isError }) + } + : null + } + if (value.type === 'tool-call') { + return typeof value.name === 'string' && value.name.length > 0 + ? { + type: 'tool-call', + name: clippedLabel(value.name), + input: value.input, + ...(TOOL_CALL_STATES.has(value.state as string) ? { state: value.state } : {}) + } + : null + } + if (value.type !== 'image-ref') { + return null + } + return { + type: 'image-ref', + ...boundedReference(value, 'path', MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS), + ...boundedReference(value, 'url', MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS), + ...boundedReference(value, 'alt', MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS) + } +} + +/** Displayed content: the reader is told it was cut. */ +function clippedProse(value: string): string { + if (value.length <= MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS) { + return value + } + const head = value.slice(0, MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS - MARKER.length) + return `${head}${MARKER}` +} + +/** A short label, so a marker inside it would read as part of the name. */ +function clippedLabel(value: string): string { + return value.length > MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS + ? value.slice(0, MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS) + : value +} + +/** A clipped reference is a wrong reference the page would try to resolve; absent renders a + * placeholder instead. */ +function boundedReference(block: Record, key: string, maximum: number) { + const value = block[key] + return typeof value === 'string' && value.length <= maximum ? { [key]: value } : {} +} + function boundBlocks(blocks: unknown[], allowance: number): unknown[] { if (byteLength(blocks) <= allowance) { return blocks diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-stream.test.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-stream.test.ts index 2b0829e2cb6..b21af9625fe 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat-stream.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-stream.test.ts @@ -60,22 +60,18 @@ describe('opaque native-chat feed', () => { await MOBILE_WEB_NATIVE_CHAT_STREAM_METHOD.handler(f.params, f.context, emit) const messages = Array.from({ length: 8 }, (_, index) => ({ id: `message-${index}`, - blocks: [{ type: 'text', text: '界'.repeat(64_000), futureField: true }] + blocks: [{ type: 'text', text: '界'.repeat(64_000), providerFrame: { kind: 'raw' } }] })) publish({ type, messages, hasMore: true, beforeOffset: 42, futureLifecycle: 'new' }) publish({ type: 'appended', messages: [] }) const result = emit.mock.calls[1][0] expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(512 * 1024) - expect(result).toMatchObject({ - type, - hasMore: true, - beforeOffset: 42, - futureLifecycle: 'new' - }) + expect(result).toMatchObject({ type, hasMore: true, beforeOffset: 42 }) + expect(result).not.toHaveProperty('futureLifecycle') expect(result.messages).toHaveLength(8) - expect(result.messages[0].blocks[0]).toMatchObject({ - text: expect.stringContaining('(truncated)'), - futureField: true + expect(result.messages[0].blocks[0]).toEqual({ + type: 'text', + text: expect.stringContaining('(truncated)') }) expect(emit.mock.calls[2][0]).toEqual({ type: 'appended', messages: [] }) expect(f.runtime.cleanupSubscription).not.toHaveBeenCalled() @@ -90,20 +86,21 @@ describe('opaque native-chat feed', () => { }) const emit = vi.fn() await MOBILE_WEB_NATIVE_CHAT_STREAM_METHOD.handler(f.params, f.context, emit) - publish({ type: 'snapshot', messages: [], futureMetadata: 'x'.repeat(600_000) }) + publish({ + type: 'snapshot', + messages: Array.from({ length: 1_000 }, (_, index) => ({ + id: `${index}`.padEnd(1_024, 'x'), + blocks: [] + })) + }) publish({ type: 'appended', messages: [] }) expect(emit.mock.calls.map(([event]) => event.type)).toEqual(['ready', 'error']) expect(f.runtime.cleanupSubscription).toHaveBeenCalledOnce() }) - it('announces a private cleanup token and forwards future fields', async () => { + it('announces a private cleanup token and forwards the page-shaped event', async () => { const f = fixture() - const event = { - type: 'snapshot', - messages: [], - hasMore: false, - futureField: { addedByDesktop: true } - } + const event = { type: 'snapshot', messages: [], hasMore: false, pending: true } subscribe.mockImplementationOnce(async (_params, _context, emit) => emit(event)) const emit = vi.fn() await MOBILE_WEB_NATIVE_CHAT_STREAM_METHOD.handler(f.params, f.context, emit) diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts index 402c93100e6..d0b31c89847 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts @@ -23,8 +23,7 @@ describe('Desktop native-chat page adapter', () => { role: 'assistant', source: 'transcript', timestamp: index, - future: { field: true }, - blocks: [{ type: 'text', text: character.repeat(64_000), futureBlockField: 'preserved' }] + blocks: [{ type: 'text', text: character.repeat(64_000), providerFrame: { kind: 'raw' } }] })) const raw = { messages, hasMore: true, beforeOffset: 42, futureLifecycle: 'new' } read.mockResolvedValue(raw) @@ -38,11 +37,11 @@ describe('Desktop native-chat page adapter', () => { expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( MOBILE_WEB_NATIVE_CHAT_EVENT_MAX_BYTES ) - expect(result).toMatchObject({ hasMore: true, beforeOffset: 42, futureLifecycle: 'new' }) + expect(result).toMatchObject({ hasMore: true, beforeOffset: 42 }) + expect(result).not.toHaveProperty('futureLifecycle') expect(result.messages.map(({ id }) => id)).toEqual(messages.map(({ id }) => id)) for (const message of result.messages) { - expect(message.future).toEqual({ field: true }) - expect(message.blocks[0].futureBlockField).toBe('preserved') + expect(message.blocks[0]).not.toHaveProperty('providerFrame') expect(message.blocks[0].text).toContain('(truncated)') expect(message.blocks[0].text.startsWith(character)).toBe(true) } @@ -50,14 +49,14 @@ describe('Desktop native-chat page adapter', () => { } ) - it('bounds oversized tool and future blocks without discarding messages or the pagination cursor', async () => { + it('bounds oversized tool blocks without discarding messages or the pagination cursor', async () => { const f = fixture() const raw = { messages: Array.from({ length: 40 }, (_, index) => ({ id: `message-${index}`, blocks: [ - { type: 'future-block', field: 'retained' }, - { type: 'tool-call', input: { payload: 'x'.repeat(100_000) } } + { type: 'image-ref', alt: 'kept' }, + { type: 'tool-call', name: 'Bash', input: { payload: 'x'.repeat(100_000) } } ] })), hasMore: true, @@ -71,15 +70,14 @@ describe('Desktop native-chat page adapter', () => { expect(result.messages).toHaveLength(40) expect(result.beforeOffset).toBe(123) expect(result.messages[0].blocks).toEqual([ - { type: 'future-block', field: 'retained' }, + { type: 'image-ref', alt: 'kept' }, { type: 'text', text: '\n… (truncated)' } ]) }) it('reads host identities from the tab list and ignores forged read fields', async () => { const f = fixture() - const result = { messages: [{ future: { field: true } }], futureLifecycle: 'new' } - read.mockResolvedValue(result) + read.mockResolvedValue({ messages: [{ id: 'message-1' }], futureLifecycle: 'new' }) expect( await reader.handler( { @@ -94,7 +92,7 @@ describe('Desktop native-chat page adapter', () => { }, f.context ) - ).toEqual(result) + ).toEqual({ messages: [{ id: 'message-1' }] }) expect(read).toHaveBeenCalledWith( expect.objectContaining({ limit: 30, diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts index 70ddc7719d4..fd285dc2445 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts @@ -6,36 +6,33 @@ import { MOBILE_WEB_NATIVE_CHAT_READ_LIMIT, MobileWebNativeChatReadResultSchema } from '../../../../shared/mobile-web/native-chat-operation-contract' -import { tolerantMobileWebShellPayload } from '../../../../shared/mobile-web/shell-payload-tolerance' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { boundMobileWebNativeChatRead } from './mobile-web-native-chat-read-budget' import { MOBILE_NATIVE_CHAT_MAX_WINDOW, windowForClient } from './native-chat-rpc-message-sanitizer' -import { clipMobileWebNativeChatToPageContract } from './mobile-web-native-chat-page-contract-clip' -// The page parses the shell-relayed read with the tolerant rewrite, never the raw strict schema. -const pageContract = tolerantMobileWebShellPayload(MobileWebNativeChatReadResultSchema) +// The page parses the shell-relayed read with this schema and nothing else. const ESC = String.fromCharCode(27) function asPage(messages: unknown[]) { - return pageContract.safeParse({ messages, hasMore: false }) + return MobileWebNativeChatReadResultSchema.safeParse({ messages, hasMore: false }) } -function clipped(messages: unknown[]) { - const result = clipMobileWebNativeChatToPageContract({ messages, hasMore: false }) +/** The whole desktop path a mobile-web read takes: sanitize for a mobile client, then reshape to + * what the page declares. */ +function hostRead(messages: unknown[], limit = messages.length) { + const sanitized = windowForClient(messages as NativeChatMessage[], 'mobile', limit) + const result = boundMobileWebNativeChatRead({ messages: sanitized, hasMore: false }) return (result as { messages: unknown[] }).messages } -function sanitized(messages: unknown[]) { - return windowForClient(messages as NativeChatMessage[], 'mobile') -} - function message(id: string, blocks: unknown[]) { return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } } -describe('native chat sanitizer against the page contract', () => { +describe('native chat reads against the page contract', () => { it('keeps an adversarial transcript parseable and strips what the page cannot name', () => { const parsed = asPage( - sanitized([ + hostRead([ message('turn-1', [ { type: 'text', @@ -78,7 +75,7 @@ describe('native chat sanitizer against the page contract', () => { it('keeps every tool-call lifecycle state the page names', () => { const states = ['running', 'completed', 'failed'] as const const parsed = asPage( - sanitized([ + hostRead([ message( 'turn-2', states.map((state) => ({ type: 'tool-call', name: 'Bash', input: {}, state })) @@ -98,7 +95,7 @@ describe('native chat sanitizer against the page contract', () => { Array.from({ length: 200 }, (_, index) => [`k${index}`, 'v'.repeat(200)]) ) const parsed = asPage( - sanitized([message('turn-3', [{ type: 'tool-call', name: 'Bash', input: { deep, wide } }])]) + hostRead([message('turn-3', [{ type: 'tool-call', name: 'Bash', input: { deep, wide } }])]) ) expect(parsed.success).toBe(true) @@ -114,37 +111,45 @@ describe('native chat sanitizer against the page contract', () => { ) expect(MOBILE_NATIVE_CHAT_MAX_WINDOW).toBe(MOBILE_WEB_NATIVE_CHAT_READ_LIMIT) - const parsed = asPage( - windowForClient(messages as NativeChatMessage[], 'mobile', messages.length) - ) + const parsed = asPage(hostRead(messages)) expect(parsed.success).toBe(true) expect(parsed.data?.messages).toHaveLength(MOBILE_WEB_NATIVE_CHAT_READ_LIMIT) }) - // The desktop adapters clip sanitizer output to the page contract before it leaves the host; - // these prove the raw sanitizer alone still needs that pass. - it('text over the page ceiling needs the page-contract clip', () => { + // The sanitizer alone still overruns the page contract; these prove the reshape closes each gap. + it('clips text over the page ceiling instead of losing the block', () => { const text = 'a'.repeat(MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS + 1) - const raw = asPage(sanitized([message('turn-4', [{ type: 'text', text }])])) - expect(raw.data?.messages[0]?.blocks).toEqual([]) + const raw = windowForClient( + [message('turn-4', [{ type: 'text', text }])] as NativeChatMessage[], + 'mobile' + ) - const bounded = asPage(clipped(sanitized([message('turn-4', [{ type: 'text', text }])]))) - expect(bounded.data?.messages[0]?.blocks[0]).toMatchObject({ type: 'text' }) + expect(asPage(raw).success).toBe(false) + const parsed = asPage(hostRead([message('turn-4', [{ type: 'text', text }])])) + expect(parsed.success).toBe(true) + expect(parsed.data?.messages[0]?.blocks[0]).toMatchObject({ type: 'text' }) }) - it('a turn over the page block limit needs the page-contract clip', () => { + it('holds a turn to the page block limit', () => { const blocks = Array.from({ length: MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT + 1 }, () => ({ type: 'text', text: 'hi' })) - expect(asPage(sanitized([message('turn-5', blocks)])).success).toBe(false) - expect(asPage(clipped(sanitized([message('turn-5', blocks)]))).success).toBe(true) + const raw = windowForClient([message('turn-5', blocks)] as NativeChatMessage[], 'mobile') + + expect(asPage(raw).success).toBe(false) + expect(asPage(hostRead([message('turn-5', blocks)])).success).toBe(true) }) - it('a message id over the page ceiling needs the page-contract clip', () => { + it('clips a message id over the page ceiling', () => { const id = 'x'.repeat(MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS + 1) - expect(asPage(sanitized([message(id, [{ type: 'text', text: 'hi' }])])).success).toBe(false) - const parsed = asPage(clipped(sanitized([message(id, [{ type: 'text', text: 'hi' }])]))) + const raw = windowForClient( + [message(id, [{ type: 'text', text: 'hi' }])] as NativeChatMessage[], + 'mobile' + ) + + expect(asPage(raw).success).toBe(false) + const parsed = asPage(hostRead([message(id, [{ type: 'text', text: 'hi' }])])) expect(parsed.success).toBe(true) expect(parsed.data?.messages[0]?.id).toHaveLength( MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts b/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts index 97a5781b7ae..3527b1a93ca 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts @@ -1,4 +1,3 @@ -import { tolerantMobileWebShellPayload } from '../../shared/mobile-web/shell-payload-tolerance' import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import type { MobileWebActiveSubscription } from './mobile-web-bridge-subscription-state' @@ -18,9 +17,7 @@ export function deliverMobileWebSubscriptionEvent( fail(new MobileWebBridgeClientError('invalid_message', true)) return } - // The shell that authored this event can be a newer release than the page reading it, and a - // schema failure here is permanent, so parse forgivingly in that direction only. - const parsed = tolerantMobileWebShellPayload(subscription.eventSchema).safeParse(message.payload) + const parsed = subscription.eventSchema.safeParse(message.payload) if (!parsed.success) { fail(new MobileWebBridgeClientError('invalid_message', false)) return diff --git a/src/mobile-web/src/mobile-web-markdown-request-client.test.ts b/src/mobile-web/src/mobile-web-markdown-request-client.test.ts index b16738e58b6..ceed0df2165 100644 --- a/src/mobile-web/src/mobile-web-markdown-request-client.test.ts +++ b/src/mobile-web/src/mobile-web-markdown-request-client.test.ts @@ -34,7 +34,9 @@ describe('mobile web markdown request client', () => { response( operation === 'markdownDraftRead' ? { ...TARGET, draft: { contentBase64, baseVersion: 'v1' } } - : { ...TARGET, contentBase64, baseVersion: 'v1', editable: true, stale: false } + : operation === 'markdownSave' + ? { ...TARGET, contentBase64, baseVersion: 'v1' } + : { ...TARGET, contentBase64, baseVersion: 'v1', editable: true, stale: false } ) ) await expect(pending).resolves.toMatchObject({ content, baseVersion: 'v1' }) diff --git a/src/mobile-web/src/mobile-web-one-shot-request-client.ts b/src/mobile-web/src/mobile-web-one-shot-request-client.ts index 48dc1a75029..58427a72424 100644 --- a/src/mobile-web/src/mobile-web-one-shot-request-client.ts +++ b/src/mobile-web/src/mobile-web-one-shot-request-client.ts @@ -6,7 +6,6 @@ import { type MobileWebBridgePageMessage, type MobileWebBridgeShellMessage } from '../../shared/mobile-web/bridge-contract' -import { tolerantMobileWebShellPayload } from '../../shared/mobile-web/shell-payload-tolerance' import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import { encodedMobileWebBridgeValueByteLength } from './mobile-web-bridge-request-encoding' import { @@ -139,8 +138,7 @@ export class MobileWebOneShotRequestClient { ) return true } - // Shell->page: tolerate a newer shell's additive result rather than failing unretryably. - const parsed = tolerantMobileWebShellPayload(pending.resultSchema).safeParse(message.payload) + const parsed = pending.resultSchema.safeParse(message.payload) if (!parsed.success) { this.finishWithError( message.requestId, diff --git a/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts b/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts index 777cbcb87a6..c188895a447 100644 --- a/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts +++ b/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts @@ -65,7 +65,7 @@ describe('mobile web source-control sync request client', () => { }) }) - it('rejects cross-request action identity and strips undeclared host fields', async () => { + it('rejects cross-request action identity and undeclared host fields', async () => { const checkoutHarness = createHarness() const checkout = checkoutHarness.client.sourceControlCheckout({ workspaceId: 'workspace-1', @@ -87,9 +87,6 @@ describe('mobile web source-control sync request client', () => { ) await expect(checkout).rejects.toMatchObject({ code: 'invalid_message' }) - // An undeclared host field must not reach the page, but rejecting the whole result made one - // additive field from a newer shell a permanent `invalid_message`. Stripping keeps the leak - // fenced and the payload usable. const upstreamHarness = createHarness() const request = upstreamHarness.client.sourceControlUpstream({ workspaceId: 'workspace-1' }) upstreamHarness.client.receive( @@ -98,7 +95,7 @@ describe('mobile web source-control sync request client', () => { hostPath: '/private/repository' }) ) - await expect(request).resolves.not.toHaveProperty('hostPath') + await expect(request).rejects.toMatchObject({ code: 'invalid_message' }) }) it('cancels a pending sync request when its workspace owner replaces it', async () => { diff --git a/src/mobile-web/src/native-shell-channel.test.tsx b/src/mobile-web/src/native-shell-channel.test.tsx index b11ecc26eae..18b9a59b8b7 100644 --- a/src/mobile-web/src/native-shell-channel.test.tsx +++ b/src/mobile-web/src/native-shell-channel.test.tsx @@ -161,7 +161,7 @@ describe('mobile web native shell channel', () => { expect(posted.at(-1)).toMatchObject({ type: 'cancel', target: 'request' }) }) - it('opens its default route when a newer shell resumes a route kind it cannot name', () => { + it('drops an init whose resume route kind it cannot name', () => { const target = window as NativeTestWindow target.OrcaNative = { postMessage: () => {} } const hook = renderHook(() => useMobileWebNativeShell(), { @@ -179,8 +179,9 @@ describe('mobile web native shell channel', () => { ) ) - // Why: dropping the init instead would cost the page every grant, not one route. - expect(hook.result.current.client).not.toBeNull() + // The shell and the page ship as one release pair, so an unnameable route kind is a bug in + // the pair, not skew: the page stays uninitialised rather than guessing. + expect(hook.result.current.client).toBeNull() expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' }) expect(hook.result.current.navigationRoute).toEqual({ kind: 'workspaceList' }) }) diff --git a/src/shared/mobile-web/bridge-contract-adversarial.test.ts b/src/shared/mobile-web/bridge-contract-adversarial.test.ts index 1c1a582fbb3..b541054107b 100644 --- a/src/shared/mobile-web/bridge-contract-adversarial.test.ts +++ b/src/shared/mobile-web/bridge-contract-adversarial.test.ts @@ -80,17 +80,13 @@ describe('mobile web bridge adversarial corpus', () => { }) }) - // An undeclared key on a shell frame is stripped, not fatal: the shell can be a newer release - // than the page, and dropping the frame costs the page the whole message. The key still never - // reaches the page, so the leak fence is unchanged. - it('strips an undeclared shell field instead of dropping the frame', () => { - const parsed = parseMobileWebBridgeShellMessage( - JSON.stringify(shellEvent({ hostPath: '/private/repo' })), - CONTEXT - ) - - expect(parsed).toMatchObject({ ok: true }) - expect(parsed.ok && parsed.value).not.toHaveProperty('hostPath') + it('drops a shell frame carrying an undeclared field', () => { + expect( + parseMobileWebBridgeShellMessage( + JSON.stringify(shellEvent({ hostPath: '/private/repo' })), + CONTEXT + ) + ).toMatchObject({ ok: false }) }) }) diff --git a/src/shared/mobile-web/bridge-contract.test.ts b/src/shared/mobile-web/bridge-contract.test.ts index 4dac5cdf533..11a49f37b5d 100644 --- a/src/shared/mobile-web/bridge-contract.test.ts +++ b/src/shared/mobile-web/bridge-contract.test.ts @@ -371,26 +371,24 @@ describe('mobile web bridge shell contract', () => { ).toEqual({ ok: false, error: 'too_large' }) }) - // Stripped rather than rejected: `init` carries every grant, so dropping the frame over one - // undeclared key from a newer shell costs the page every capability. The key is still never - // readable by the page, which is the whole point of the fence. + // The shell and the page ship as one release pair, so an init carrying a key the page never + // declared is a broken shell, not version skew. Fail closed: the page reads no privileged state. it.each(['hostId', 'hostIdentity', 'publicKeyB64', 'deviceToken', 'endpoint', 'credential'])( - 'strips privileged %s state from the initial page message', + 'refuses an initial page message carrying privileged %s state', (field) => { - const parsed = parseMobileWebBridgeInitialMessage( - JSON.stringify({ - version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - type: 'init', - shellSessionId: SHELL_SESSION_ID, - buildId: BUILD_ID, - connection: 'connected', - grants: [operationGrant()], - [field]: 'credential-secret' - }) - ) - - expect(parsed).toMatchObject({ ok: true }) - expect(parsed.ok && parsed.value).not.toHaveProperty(field) + expect( + parseMobileWebBridgeInitialMessage( + JSON.stringify({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'init', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID, + connection: 'connected', + grants: [operationGrant()], + [field]: 'credential-secret' + }) + ) + ).toEqual({ ok: false, error: 'invalid_message' }) } ) @@ -407,36 +405,29 @@ describe('mobile web bridge shell contract', () => { ).toBe(false) }) - it('degrades a resume route kind a newer shell added instead of failing the whole init', () => { - const base = { + it('refuses an init carrying a resume route kind it cannot name', () => { + const raw = JSON.stringify({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type: 'init', shellSessionId: SHELL_SESSION_ID, buildId: BUILD_ID, connection: 'connected', - grants: [operationGrant(), operationGrant({ capability: 'terminal', operation: 'input' })] - } - const raw = JSON.stringify({ - ...base, + grants: [operationGrant(), operationGrant({ capability: 'terminal', operation: 'input' })], resumeRoute: { kind: 'someFutureKind', workspaceId: 'opaque-workspace' } }) - // Why: init is the page's only grant delivery, so a route it cannot name must cost the route. - for (const parsed of [ - parseMobileWebBridgeShellMessage(raw, CONTEXT), - parseMobileWebBridgeInitialMessage(raw) - ]) { - expect(parsed.ok).toBe(true) - const value = (parsed as Extract).value as { - resumeRoute?: unknown - grants: unknown[] - } - expect(value.resumeRoute).toBeUndefined() - expect(value.grants).toHaveLength(2) - } + // A shell that adds a route kind bumps MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, which is the gate. + expect(parseMobileWebBridgeShellMessage(raw, CONTEXT)).toEqual({ + ok: false, + error: 'invalid_message' + }) + expect(parseMobileWebBridgeInitialMessage(raw)).toEqual({ + ok: false, + error: 'invalid_message' + }) }) - it('rejects unbounded resume routes and strips host-shaped ones', () => { + it('rejects unbounded and host-shaped resume routes', () => { const base = { version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type: 'init', @@ -469,25 +460,20 @@ describe('mobile web bridge shell contract', () => { ) ).toEqual({ ok: false, error: 'invalid_message' }) - const parsed = parseMobileWebBridgeShellMessage( - JSON.stringify({ - ...base, - resumeRoute: { - kind: 'session', - workspaceId: 'opaque-workspace', - workspaceName: 'Feature', - hostPath: '/private/worktree' - } - }), - CONTEXT - ) - expect(parsed).toMatchObject({ ok: true }) - expect(parsed.ok && parsed.value).toMatchObject({ - resumeRoute: { kind: 'session', workspaceName: 'Feature' } - }) - expect(parsed.ok && (parsed.value as { resumeRoute: object }).resumeRoute).not.toHaveProperty( - 'hostPath' - ) + expect( + parseMobileWebBridgeShellMessage( + JSON.stringify({ + ...base, + resumeRoute: { + kind: 'session', + workspaceId: 'opaque-workspace', + workspaceName: 'Feature', + hostPath: '/private/worktree' + } + }), + CONTEXT + ) + ).toEqual({ ok: false, error: 'invalid_message' }) }) it('bounds the optional local host display name', () => { @@ -668,20 +654,16 @@ describe('mobile web bridge shell contract', () => { } expect(MobileWebBridgeShellMessageSchema.safeParse(response).success).toBe(true) - // The message is stripped rather than fatal, so the page keeps the error code it can act on - // and still cannot read the host path inside the message. - const parsed = parseMobileWebBridgeShellMessage( - JSON.stringify({ - ...response, - error: { ...response.error, message: '/private/path: permission denied' } - }), - CONTEXT - ) - expect(parsed).toMatchObject({ ok: true }) - expect(parsed.ok && parsed.value).toMatchObject({ - error: { code: 'host_error', retryable: true } - }) - expect(parsed.ok && (parsed.value as { error: object }).error).not.toHaveProperty('message') + // A raw host message is never a field the page can read, so the frame carrying one is refused. + expect( + parseMobileWebBridgeShellMessage( + JSON.stringify({ + ...response, + error: { ...response.error, message: '/private/path: permission denied' } + }), + CONTEXT + ) + ).toEqual({ ok: false, error: 'invalid_message' }) }) it('parses matching shell events and rejects stale subscription events', () => { diff --git a/src/shared/mobile-web/bridge-contract.ts b/src/shared/mobile-web/bridge-contract.ts index c53dc43d305..0dc33cea5e2 100644 --- a/src/shared/mobile-web/bridge-contract.ts +++ b/src/shared/mobile-web/bridge-contract.ts @@ -22,7 +22,6 @@ import { MobileWebNavigationRouteSchema, MobileWebResumeRouteSchema } from './bridge-route-contract' -import { tolerantMobileWebShellPayload } from './shell-payload-tolerance' export { isMobileWebBridgeOperation, @@ -269,28 +268,17 @@ export function parseMobileWebBridgePageMessage( return parseMobileWebBridgeMessage(raw, expected, MobileWebBridgePageMessageSchema) } -/** - * Shell->page frames are authored by an APK that can be newer than the page reading them, and a - * frame the page cannot parse is dropped whole — for `init` that is every capability lost, not one - * field. Parsing through the tolerant view strips a key the page does not declare instead of - * failing the frame, which also keeps the PII fence: an undeclared `hostPath` or raw error - * `message` never reaches the page either way. Page->shell stays strict; the shell is the - * authority there. - */ -const TolerantShellMessageSchema = tolerantMobileWebShellPayload(MobileWebBridgeShellMessageSchema) -const TolerantShellInitSchema = tolerantMobileWebShellPayload(ShellInitSchema) - export function parseMobileWebBridgeShellMessage( raw: string, expected: MobileWebBridgeMessageContext ): MobileWebBridgeParseResult { - return parseMobileWebBridgeMessage(raw, expected, TolerantShellMessageSchema) + return parseMobileWebBridgeMessage(raw, expected, MobileWebBridgeShellMessageSchema) } export function parseMobileWebBridgeInitialMessage( raw: string ): MobileWebBridgeParseResult> { - return parseMobileWebBridgeMessageDocument(raw, TolerantShellInitSchema) + return parseMobileWebBridgeMessageDocument(raw, ShellInitSchema) } function validateRequestOperation( diff --git a/src/shared/mobile-web/native-chat-operation-contract.ts b/src/shared/mobile-web/native-chat-operation-contract.ts index f4c7057e90e..a616a27def0 100644 --- a/src/shared/mobile-web/native-chat-operation-contract.ts +++ b/src/shared/mobile-web/native-chat-operation-contract.ts @@ -147,6 +147,9 @@ export const MobileWebNativeChatEventSchema = z.discriminatedUnion('type', [ hasMore: z.boolean().optional(), beforeOffset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), error: z.string().min(1).max(512).optional(), + // The host answers a transcript it has not drained yet with an empty snapshot; without this + // the chat view cannot tell "no messages" from "not read yet". + pending: z.boolean().optional(), lifecycle: MobileWebNativeChatLifecycleSchema.optional() }) .strict(), diff --git a/src/shared/mobile-web/shell-payload-tolerance-census.test.ts b/src/shared/mobile-web/shell-payload-tolerance-census.test.ts deleted file mode 100644 index 0a11c15598b..00000000000 --- a/src/shared/mobile-web/shell-payload-tolerance-census.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { beforeAll, describe, expect, it } from 'vitest' -import type { z } from 'zod' -import { - MobileWebBridgePageMessageSchema, - MobileWebBridgeShellMessageSchema -} from './bridge-contract' -import { tolerantMobileWebShellPayload } from './shell-payload-tolerance' - -const PAGE_DIR = resolve(__dirname, '..', '..', 'mobile-web', 'src') - -/** Every exported schema in every contract module, by export name. */ -async function exportedSchemas(): Promise>> { - const schemas = new Map>() - const files = readdirSync(__dirname).filter( - (name) => name.endsWith('-contract.ts') && !name.includes('.test.') - ) - for (const file of files) { - const module = (await import(/* @vite-ignore */ `./${file.slice(0, -3)}`)) as Record< - string, - unknown - > - for (const [name, value] of Object.entries(module)) { - if (name.endsWith('Schema') && isSchema(value)) { - schemas.set(name, value) - } - } - } - return schemas -} - -function isSchema(value: unknown): value is z.ZodType { - return typeof value === 'object' && value !== null && '_zod' in value -} - -/** - * Schema names the page parses in the shell->page direction: the result schema of every one-shot - * request and the event schema of every subscription. Derived from the page source so a new - * operation joins the ratchet without anyone remembering to list it. - */ -function shellAuthoredSchemaNames(): Set { - const names = new Set() - for (const file of readdirSync(PAGE_DIR).filter( - (name) => name.endsWith('.ts') && !name.includes('.test.') - )) { - const text = readFileSync(join(PAGE_DIR, file), 'utf8') - for (const match of text.matchAll( - /\.request(?:<[^(]*>)?\(\s*'[A-Za-z]+',\s*'[A-Za-z0-9]+',([\s\S]{0,400}?)\n\s*\)/g - )) { - const schemas = [...match[1]!.matchAll(/\b([A-Za-z0-9_]*Schema)\b/g)].map((name) => name[1]!) - if (schemas.length === 2) { - names.add(schemas[1]!) - } - } - for (const match of text.matchAll(/\beventSchema:\s*([A-Za-z0-9_]*Schema)\b/g)) { - names.add(match[1]!) - } - } - return names -} - -/** Object nodes that still reject unknown keys, reached through any `_zod.def` child. */ -function strictPaths(schema: z.ZodType): string[] { - const found: string[] = [] - const seen = new Set() - const visit = (node: unknown, path: string): void => { - if (isSchema(node)) { - if (seen.has(node)) { - return - } - seen.add(node) - const def = (node as unknown as { _zod: { def: Record } })._zod.def - const catchall = def.catchall - if ( - def.type === 'object' && - isSchema(catchall) && - (catchall as unknown as { _zod: { def: { type: string } } })._zod.def.type === 'never' - ) { - found.push(path) - } - visit(def, path) - return - } - if (Array.isArray(node)) { - node.forEach((entry, index) => visit(entry, `${path}[${index}]`)) - return - } - if (typeof node === 'object' && node !== null) { - for (const [key, value] of Object.entries(node)) { - // A `lazy` getter only reveals its subtree when called; no other function in a def is safe - // to invoke. - visit(key === 'getter' && typeof value === 'function' ? value() : value, `${path}.${key}`) - } - } - } - visit(schema, '') - return found -} - -describe('mobile web shell payload tolerance census', () => { - let schemas: Map> - const derived = shellAuthoredSchemaNames() - - beforeAll(async () => { - schemas = await exportedSchemas() - }) - - it('derives the shell-authored schema set from the page instead of a hand list', () => { - expect(schemas.size).toBeGreaterThanOrEqual(300) - expect(derived.size).toBeGreaterThanOrEqual(100) - expect([...derived]).toContain('MobileWebNativeAlertResultSchema') - expect([...derived]).toContain('MobileWebHostResultSchema') - expect([...derived]).not.toContain('MobileWebSessionSnapshotResultSchema') - expect([...derived].filter((name) => !schemas.has(name))).toEqual([]) - }) - - it('preserves host-authored product fields through the generic shell result', () => { - const snapshot = { tabs: [{ kind: 'future-session-kind', future: { enabled: true } }] } - expect( - tolerantMobileWebShellPayload(schemas.get('MobileWebHostResultSchema')!).parse(snapshot) - ).toEqual(snapshot) - }) - - // Without this the ratchet below could pass by finding nothing at all. - it('finds the strict nodes the transform is supposed to open', () => { - expect( - strictPaths(schemas.get('MobileWebSessionSnapshotResultSchema')!).length - ).toBeGreaterThan(4) - }) - - // A `.strict()` node anywhere under a shell-authored payload makes one additive field from a - // newer APK a permanent `invalid_message` on an older page. The transform has to reach all of - // them, including through a wrapper it does not yet know about. - it('leaves no strict object under any schema the page parses from the shell', () => { - const offenders: Record = {} - for (const name of [ - ...derived, - ...[...schemas.keys()].filter((name) => /(Result|Event)Schema$/.test(name)) - ]) { - const paths = strictPaths(tolerantMobileWebShellPayload(schemas.get(name)!)) - if (paths.length > 0) { - offenders[name] = paths - } - } - - expect(offenders).toEqual({}) - }) - - // The envelope is the same hazard one level up: an additive field on `init` from a newer APK - // used to fail the union, and a dropped `init` costs the page every grant at once. - it('leaves no strict object under the shell->page envelope', () => { - expect(strictPaths(MobileWebBridgeShellMessageSchema).length).toBeGreaterThan(8) - expect(strictPaths(tolerantMobileWebShellPayload(MobileWebBridgeShellMessageSchema))).toEqual( - [] - ) - }) - - it('keeps the page->shell request schemas strict', () => { - const payloads = [...schemas.keys()].filter((name) => name.endsWith('PayloadSchema')) - const open = payloads.filter((name) => strictPaths(schemas.get(name)!).length === 0) - - expect(payloads.length).toBeGreaterThanOrEqual(50) - expect(open.length).toBeLessThan(payloads.length / 2) - expect(strictPaths(MobileWebBridgePageMessageSchema).length).toBeGreaterThan(4) - }) -}) diff --git a/src/shared/mobile-web/shell-payload-tolerance.test.ts b/src/shared/mobile-web/shell-payload-tolerance.test.ts deleted file mode 100644 index 22ca8c94e60..00000000000 --- a/src/shared/mobile-web/shell-payload-tolerance.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { z } from 'zod' -import { MobileWebNativeChatReadResultSchema } from './native-chat-operation-contract' -import { MobileWebSessionSnapshotResultSchema } from './session-operation-contract' -import { tolerantMobileWebShellPayload } from './shell-payload-tolerance' - -const SNAPSHOT = { - workspaceId: 'workspace-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 3, - activeTabId: 'tab-1', - activeTabType: 'terminal' as const, - tabs: [{ id: 'tab-1', title: 'Terminal', isActive: true, type: 'terminal', status: 'ready' }], - truncated: false -} - -describe('mobile web shell payload tolerance', () => { - const snapshot = tolerantMobileWebShellPayload(MobileWebSessionSnapshotResultSchema) - - it('keeps a newer shell snapshot readable by dropping only what the page cannot name', () => { - const parsed = snapshot.safeParse({ - ...SNAPSHOT, - activeTabType: 'canvas', - sessionRevision: 9, - tabs: [ - { ...SNAPSHOT.tabs[0], pinned: true }, - { id: 'tab-2', title: 'Canvas', isActive: false, type: 'canvas', documentId: 'd1' } - ] - }) - - expect(parsed.success).toBe(true) - expect(parsed.data).toEqual({ - workspaceId: 'workspace-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 3, - activeTabId: 'tab-1', - activeTabType: null, - tabs: [{ id: 'tab-1', title: 'Terminal', isActive: true, type: 'terminal', status: 'ready' }], - truncated: false - }) - }) - - it('collapses an unknown value for an optional closed set instead of failing the payload', () => { - const parsed = snapshot.safeParse({ ...SNAPSHOT, workspaceTransportState: 'degraded' }) - - expect(parsed.success).toBe(true) - expect((parsed.data as { workspaceTransportState?: string }).workspaceTransportState).toBe( - undefined - ) - }) - - it('collapses an optional discriminated union the page cannot classify', () => { - const schema = tolerantMobileWebShellPayload( - z - .object({ - keep: z.string(), - route: z - .discriminatedUnion('kind', [ - z.object({ kind: z.literal('list') }).strict(), - z.object({ kind: z.literal('session'), id: z.string() }).strict() - ]) - .optional() - }) - .strict() - ) - - expect(schema.safeParse({ keep: 'a', route: { kind: 'futureKind', id: 'x' } })).toEqual({ - success: true, - data: { keep: 'a' } - }) - expect(schema.safeParse({ keep: 'a', route: { kind: 'session', id: 'x' } })).toEqual({ - success: true, - data: { keep: 'a', route: { kind: 'session', id: 'x' } } - }) - expect(schema.safeParse({ keep: 'a' }).success).toBe(true) - // A member the page CAN name but whose fields are wrong is a sender bug, not skew. - expect(schema.safeParse({ keep: 'a', route: { kind: 'session' } }).success).toBe(false) - expect(schema.safeParse({ keep: 'a', route: 'session' }).success).toBe(false) - }) - - it('still rejects a payload whose known fields are wrong, and keeps refinements', () => { - expect(snapshot.safeParse({ ...SNAPSHOT, snapshotVersion: -1 }).success).toBe(false) - expect(snapshot.safeParse({ ...SNAPSHOT, truncated: 'no' }).success).toBe(false) - - const echoed = tolerantMobileWebShellPayload( - MobileWebSessionSnapshotResultSchema.refine((event) => event.workspaceId === 'workspace-1') - ) - expect(echoed.safeParse(SNAPSHOT).success).toBe(true) - expect(echoed.safeParse({ ...SNAPSHOT, workspaceId: 'workspace-2' }).success).toBe(false) - }) - - it('keeps the wire-size cap ahead of member parsing on an array of unions', () => { - const capped = tolerantMobileWebShellPayload( - z.object({ - items: z - .array(z.discriminatedUnion('type', [z.object({ type: z.literal('a') }).strict()])) - .max(2) - }) - ) - - expect(capped.safeParse({ items: [{ type: 'a' }, { type: 'b' }] }).data).toEqual({ - items: [{ type: 'a' }] - }) - expect(capped.safeParse({ items: [{ type: 'a' }, { type: 'a' }, { type: 'a' }] }).success).toBe( - false - ) - }) - - it('reaches strictness nested behind wrappers the contracts actually use', () => { - const nested = tolerantMobileWebShellPayload( - z.object({ - entry: z.object({ id: z.string() }).strict().optional(), - pages: z.record(z.string(), z.object({ id: z.string() }).strict()), - pair: z.tuple([z.object({ id: z.string() }).strict()]), - later: z.lazy(() => z.object({ id: z.string() }).strict()) - }) - ) - - expect( - nested.safeParse({ - entry: { id: 'a', extra: 1 }, - pages: { one: { id: 'b', extra: 1 } }, - pair: [{ id: 'c', extra: 1 }], - later: { id: 'd', extra: 1 } - }) - ).toEqual({ - success: true, - data: { - entry: { id: 'a' }, - pages: { one: { id: 'b' } }, - pair: [{ id: 'c' }], - later: { id: 'd' } - } - }) - }) - - it('reads a working mode an older page cannot name as a foreground agent', () => { - const parsed = snapshot.safeParse({ - ...SNAPSHOT, - tabs: [ - { - ...SNAPSHOT.tabs[0], - agentStatus: { state: 'working', workingMode: 'hibernating' } - } - ] - }) - - expect(parsed.success).toBe(true) - const tab = parsed.data?.tabs[0] - expect(tab?.type === 'terminal' ? tab.agentStatus : undefined).toEqual({ state: 'working' }) - }) - - it('keeps a tool call whose lifecycle state an older page cannot name', () => { - const transcript = tolerantMobileWebShellPayload(MobileWebNativeChatReadResultSchema) - - const parsed = transcript.safeParse({ - messages: [ - { - id: 'm1', - role: 'assistant', - timestamp: 1, - source: 'transcript', - blocks: [{ type: 'tool-call', name: 'Bash', input: {}, state: 'queued' }] - } - ], - hasMore: false - }) - - expect(parsed.success).toBe(true) - expect(parsed.data?.messages[0]?.blocks[0]).toEqual({ - type: 'tool-call', - name: 'Bash', - input: {} - }) - }) - - it('leaves the source schema strict so page->shell requests keep their fence', () => { - expect( - MobileWebSessionSnapshotResultSchema.safeParse({ ...SNAPSHOT, sessionRevision: 9 }).success - ).toBe(false) - }) -}) diff --git a/src/shared/mobile-web/shell-payload-tolerance.ts b/src/shared/mobile-web/shell-payload-tolerance.ts deleted file mode 100644 index fb667f2084e..00000000000 --- a/src/shared/mobile-web/shell-payload-tolerance.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { z } from 'zod' - -type AnySchema = z.ZodType -type SchemaDef = Record & { type: string } - -const rewritten = new WeakMap() - -/** - * Rewrites a shell-authored payload schema so an additive change in a newer APK degrades instead of - * bricking an older page. The shell (APK) and the page (served by the desktop) ship from different - * releases, and a page parse failure is permanent: `invalid_message` is not retryable and nothing - * re-subscribes. Four relaxations, each the forward-compatible reading of a closed shape: unknown - * object keys are stripped rather than rejected, a member an array-of-unions cannot classify is - * dropped rather than failing the whole array, an unknown value for an optional/nullable closed - * set collapses to absent rather than failing its parent, and an optional/nullable discriminated - * union the page cannot classify collapses the same way. - * - * Only the shell->page direction. Page->shell request schemas stay `.strict()`: there the shell is - * the authority and a loud `invalid_request` is the security fence. - */ -export function tolerantMobileWebShellPayload(schema: z.ZodType): z.ZodType { - return loosen(schema as AnySchema) as unknown as z.ZodType -} - -function loosen(schema: AnySchema): AnySchema { - const cached = rewritten.get(schema) - if (cached) { - return cached - } - const built = rebuild(schema) - rewritten.set(schema, built) - return built -} - -function definitionOf(schema: AnySchema): SchemaDef { - return (schema as unknown as { _zod: { def: SchemaDef } })._zod.def -} - -function cloned(schema: AnySchema, def: SchemaDef): AnySchema { - return (schema as unknown as { clone: (def: SchemaDef) => AnySchema }).clone(def) -} - -function rebuild(schema: AnySchema): AnySchema { - const def = definitionOf(schema) - switch (def.type) { - case 'object': - return rebuiltObject(schema, def) - case 'array': - return rebuiltArray(schema, def) - case 'union': - return cloned(schema, { ...def, options: (def.options as AnySchema[]).map(loosen) }) - case 'optional': - case 'nullable': - return rebuiltClosedSetWrapper(schema, def) - case 'nonoptional': - case 'readonly': - case 'default': - case 'prefault': - case 'catch': - case 'promise': - return cloned(schema, { ...def, innerType: loosen(def.innerType as AnySchema) }) - case 'lazy': { - const getter = def.getter as () => AnySchema - return cloned(schema, { ...def, getter: () => loosen(getter()) }) - } - case 'pipe': - return cloned(schema, { - ...def, - in: loosen(def.in as AnySchema), - out: loosen(def.out as AnySchema) - }) - case 'intersection': - return cloned(schema, { - ...def, - left: loosen(def.left as AnySchema), - right: loosen(def.right as AnySchema) - }) - case 'record': - case 'map': - case 'set': - return cloned(schema, { ...def, valueType: loosen(def.valueType as AnySchema) }) - case 'tuple': - return cloned(schema, { - ...def, - items: (def.items as AnySchema[]).map(loosen), - rest: def.rest ? loosen(def.rest as AnySchema) : def.rest - }) - default: - return schema - } -} - -function rebuiltObject(schema: AnySchema, def: SchemaDef): AnySchema { - const shape = Object.fromEntries( - Object.entries(def.shape as Record).map(([key, value]) => [ - key, - loosen(value) - ]) - ) - const catchall = def.catchall as AnySchema | undefined - const strict = catchall !== undefined && definitionOf(catchall).type === 'never' - return cloned(schema, { - ...def, - shape, - catchall: strict || catchall === undefined ? undefined : loosen(catchall) - }) -} - -/** Length checks stay on the raw array so a wire-size cap still rejects before any member parses. */ -function rebuiltArray(schema: AnySchema, def: SchemaDef): AnySchema { - const element = loosen(def.element as AnySchema) - if (!isUnion(def.element as AnySchema)) { - return cloned(schema, { ...def, element }) - } - return cloned(schema, { ...def, element: z.unknown() }).transform((items) => - (items as unknown[]).flatMap((item) => { - const parsed = element.safeParse(item) - return parsed.success ? [parsed.data] : [] - }) - ) as unknown as AnySchema -} - -/** An unknown member of a closed set reads as "absent" so it cannot fail the payload around it. */ -function rebuiltClosedSetWrapper(schema: AnySchema, def: SchemaDef): AnySchema { - const inner = def.innerType as AnySchema - const absent = (def.type === 'nullable' ? null : undefined) as never - const loosened = loosen(inner) - if (isClosedSet(inner)) { - return cloned(schema, { ...def, innerType: loosened }).catch(absent) - } - const unclassified = unclassifiedMemberOf(loosened, absent) - return cloned(schema, { - ...def, - innerType: unclassified ? z.union([loosened, unclassified]) : loosened - }) -} - -/** - * A discriminated union is a closed set one level in, so a member named by a discriminant this build - * has never heard of is the same forward-compatible shape as an unknown enum value and reads as - * absent. `init.resumeRoute` is the case that made this load-bearing: a page that failed the whole - * envelope over a route it could have ignored lost every grant with it. Scoped to an unrecognized - * discriminant on purpose -- a member the page CAN name but whose fields break their bounds is a - * sender bug, not version skew, and still fails loudly. - */ -function unclassifiedMemberOf(schema: AnySchema, absent: never): AnySchema | null { - const def = definitionOf(schema) - if (def.type !== 'union' || typeof def.discriminator !== 'string') { - return null - } - const discriminator = def.discriminator - const known = (schema as unknown as { _zod: { propValues?: Record> } })._zod - .propValues?.[discriminator] - if (!known || known.size === 0) { - return null - } - return z - .unknown() - .refine( - (value) => - typeof value === 'object' && - value !== null && - !known.has((value as Record)[discriminator]) - ) - .transform(() => absent) as unknown as AnySchema -} - -function isUnion(schema: AnySchema): boolean { - return definitionOf(schema).type === 'union' -} - -function isClosedSet(schema: AnySchema): boolean { - const def = definitionOf(schema) - if (def.type === 'enum' || def.type === 'literal') { - return true - } - if (def.type === 'optional' || def.type === 'nullable') { - return isClosedSet(def.innerType as AnySchema) - } - return def.type === 'union' && (def.options as AnySchema[]).every(isClosedSet) -}