mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
refactor(mobile-web): parse shell payloads with the plain contract schema
The tolerant rewrite existed to absorb skew between a shell and a page that ship separately, but the protocol version is the compat gate and hybrid has no released users. Deleting it means the host must emit exactly what the page declares, so the mobile-web transcript read reshapes blocks to the contract instead of relying on the page to strip host-only detail. Two gaps the rewrite was hiding are now closed in the contract: the pending-snapshot flag the shared chat view consumes, and the block fields the page never named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, key: string, maximum: number) {
|
||||
const value = block[key]
|
||||
return typeof value === 'string' && value.length > maximum ? { [key]: undefined } : {}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
+17
-21
@@ -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', () => {
|
||||
@@ -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<string, unknown>, keys: readonly string[]) {
|
||||
const shaped: Record<string, unknown> = {}
|
||||
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<string, unknown>, 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<string, unknown>, 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+36
-31
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof parsed, { ok: true }>).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', () => {
|
||||
|
||||
@@ -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<MobileWebBridgeShellMessage> {
|
||||
return parseMobileWebBridgeMessage(raw, expected, TolerantShellMessageSchema)
|
||||
return parseMobileWebBridgeMessage(raw, expected, MobileWebBridgeShellMessageSchema)
|
||||
}
|
||||
|
||||
export function parseMobileWebBridgeInitialMessage(
|
||||
raw: string
|
||||
): MobileWebBridgeParseResult<z.infer<typeof ShellInitSchema>> {
|
||||
return parseMobileWebBridgeMessageDocument(raw, TolerantShellInitSchema)
|
||||
return parseMobileWebBridgeMessageDocument(raw, ShellInitSchema)
|
||||
}
|
||||
|
||||
function validateRequestOperation(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Map<string, z.ZodType<unknown>>> {
|
||||
const schemas = new Map<string, z.ZodType<unknown>>()
|
||||
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<unknown> {
|
||||
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<string> {
|
||||
const names = new Set<string>()
|
||||
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<unknown>): string[] {
|
||||
const found: string[] = []
|
||||
const seen = new Set<unknown>()
|
||||
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<string, unknown> } })._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<string, z.ZodType<unknown>>
|
||||
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<string, string[]> = {}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
type AnySchema = z.ZodType<unknown>
|
||||
type SchemaDef = Record<string, unknown> & { type: string }
|
||||
|
||||
const rewritten = new WeakMap<object, AnySchema>()
|
||||
|
||||
/**
|
||||
* 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<T>(schema: z.ZodType<T>): z.ZodType<T> {
|
||||
return loosen(schema as AnySchema) as unknown as z.ZodType<T>
|
||||
}
|
||||
|
||||
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<string, AnySchema>).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<string, Set<unknown>> } })._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<string, unknown>)[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)
|
||||
}
|
||||
Reference in New Issue
Block a user