fix(mobile-web): make the shell to page bridge direction forward compatible

W1 (P1): every shell-authored result and event schema was .strict(), and the page fails a schema mismatch as invalid_message with retryable:false. The shell (APK) and the page (served by the desktop) ship from different releases, so one additive field or one new session tab kind from a newer APK killed the subscription and its one-shot fallback on the same byte - Loading tabs forever, surviving force-quit. tolerantMobileWebShellPayload deep-rewrites a schema at the page's two shell-payload parse sites: strict objects strip unknown keys, an array of unions drops members it cannot classify, and an unknown value for an optional/nullable closed set reads as absent. Page to shell request schemas keep .strict() - the shell is the security authority there. A census ratchet walks every contract export the page parses and fails if a strict node survives the transform.

W3 (P2): a host RPC failure collapsed into host_error, which is retryable, so method_not_found and the mobile allowlist's forbidden looked like blips. Both now map to unsupported_capability (non-retryable) through mobileWebBrokerHostRpcError, applied by codemod to the 44 regular 'if (!x.ok) throw host_error' sites.

W4 (P3): BrowserScreencastResult gains the navigation member the host already emits. Decoders unchanged.

W2 (P2): the file: confinement test gains a clientKind runtime case - pairedDeviceId is minted for scope 'runtime' too, so the fence also governs the web client, a remote desktop, and remote orca CLI.

W5 (P3): inputFloor and queryReplyAuthority stay literals with a WHY comment. Traced: the host publishes neither over the terminal stream. isMobileTerminalQueryReplyAuthority is never sent, and opcode-17 WriteUnavailable reports one refused write with no regain signal, so it is not the floor state the field declares.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 16:41:17 -04:00
parent 9b56c308da
commit 4c7fd1aec6
40 changed files with 807 additions and 83 deletions
@@ -207,6 +207,32 @@ Bridge version 2 is the first production policy. Additive operations stay on
the same version and use capability negotiation. 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:
- **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.
- **Additive field, page to shell** (any request payload) requires a grant.
Request schemas stay `.strict()` because the shell is the security authority
and must reject what it cannot account for; a newer page that sends a field
an older shell does not know gets `invalid_request`. Gate the field's use on
the operation grant that introduces it, the same way an operation is gated.
- **Additive operation, either direction** negotiates through `init.grants`.
The page fails an ungranted operation immediately with
`unsupported_capability`, so a newer page against an older shell degrades at
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. Frame *fields* do not share that
property — the envelope schemas are strict in both directions.
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
minimum has advanced. A Desktop package must declare the exact range it was
@@ -8,7 +8,7 @@ import {
MobileWebAccountSnapshotPayloadSchema
} from '../../../src/shared/mobile-web/account-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebAccountsSnapshot } from './mobile-web-account-presentation'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
@@ -61,11 +61,14 @@ export async function executeMobileWebAccountOperation(args: {
throw new MobileWebBrokerError('unsupported_capability')
}
function requireSuccess(response: { ok: boolean }): asserts response is {
function requireSuccess(response: {
ok: boolean
error?: { code?: unknown }
}): asserts response is {
ok: true
result: unknown
} {
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error ?? {})
}
}
@@ -11,7 +11,7 @@ import type { Worktree } from '../worktree/workspace-list-types'
import { deriveMobileAiVaultScopePaths } from '../agent-history/agent-history-scope-paths'
import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import type { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-authority'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
@@ -154,7 +154,7 @@ async function requestResult(
): Promise<unknown> {
const response = await client.sendRequest(method, params)
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return response.result
}
@@ -23,7 +23,7 @@ import {
type MobileAiVaultResumeProjectGroup,
type MobileAiVaultResumeRepo
} from '../agent-history/agent-history-resume-target'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import type { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-authority'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
@@ -159,7 +159,7 @@ async function loadResumeMetadata(client: RpcClient): Promise<{
requiredResult(client, 'worktree.ps', { limit: 10_000 })
])
if (!repoResponse.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(repoResponse.error)
}
const repoResult = repoResponse.result as { repos?: MobileAiVaultResumeRepo[] }
const folderWorkspaceResult = folderWorkspaceResponse as {
@@ -191,7 +191,7 @@ async function requiredResult(
): Promise<unknown> {
const response = await client.sendRequest(method, params, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return response.result
}
@@ -16,3 +16,28 @@ export function mobileWebBridgeErrorCode(error: unknown): MobileWebBridgeErrorCo
export function isRetryableMobileWebBridgeError(code: MobileWebBridgeErrorCode): boolean {
return code === 'not_connected' || code === 'timeout' || code === 'host_error'
}
/**
* A host RPC failure the page can act on. `host_error` is retryable, so collapsing every failure
* into it made "this host will never answer this call" indistinguishable from a blip: the page's
* cached package can outlive or predate the desktop release it is driving, and a method that host
* does not have answers `method_not_found` forever. `forbidden` is the mobile allowlist refusing
* the method, which is the same structural absence; the bridge has no `forbidden` code, and
* `unsupported_capability` is the one the page already handles for an operation it cannot reach.
*/
export function mobileWebBrokerHostRpcError(error: { code?: unknown }): MobileWebBrokerError {
return new MobileWebBrokerError(mobileWebBridgeErrorCodeForHostRpc(error))
}
export function mobileWebBridgeErrorCodeForHostRpc(error: {
code?: unknown
}): MobileWebBridgeErrorCode {
switch (typeof error.code === 'string' ? error.code : '') {
case 'method_not_found':
case 'method_not_supported':
case 'forbidden':
return 'unsupported_capability'
default:
return 'host_error'
}
}
@@ -1,6 +1,6 @@
import { fileUriToFilesystemPath, filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
const RESOLVE_TIMEOUT_MS = 10_000
@@ -37,7 +37,7 @@ export async function confineMobileWebBrowserFileUrl(args: {
{ timeoutMs: RESOLVE_TIMEOUT_MS }
)
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const resolved = response.result
if (
@@ -10,7 +10,7 @@ import {
import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
export async function executeMobileWebBrowserOperation(args: {
@@ -154,7 +154,7 @@ async function requireRequest(
function requireResult(response: Awaited<ReturnType<RpcClient['sendRequest']>>): unknown {
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return response.result
}
@@ -1,6 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import { activateMobileSessionFileTab } from '../session/mobile-session-file-tab-activation'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export async function executeMobileWebFileOpenOperation(args: {
client: RpcClient
@@ -13,7 +13,7 @@ export async function executeMobileWebFileOpenOperation(args: {
relativePath: args.relativePath
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
await activateMobileSessionFileTab({
client: args.client,
@@ -23,7 +23,7 @@ import {
} from '../../../src/shared/mobile-web/bridge-operation-contract'
import type { MobileWebFileWriteResult } from '../../../src/shared/mobile-web/file-edit-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { executeMobileWebFileOpenOperation } from './mobile-web-file-open-operation'
import { executeMobileWebFileWrite } from './mobile-web-file-write'
import {
@@ -73,7 +73,7 @@ export async function executeMobileWebFileOperation(args: {
relativePath: payload.relativePath
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeReadResult(
response.result,
@@ -90,7 +90,7 @@ export async function executeMobileWebFileOperation(args: {
relativePath: payload.relativePath
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeDirectoryResult(
response.result,
@@ -109,7 +109,7 @@ export async function executeMobileWebFileOperation(args: {
length: payload.length
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeChunkResult(response.result, payload)
}
@@ -143,7 +143,7 @@ async function listFiles(
limit
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeListResult(response.result, pageWorkspaceId, hostWorkspaceId, limit)
}
@@ -1,7 +1,7 @@
import { MOBILE_WEB_WORKSPACE_LIST_LIMIT } from '../../../src/shared/mobile-web/bridge-operation-contract'
import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
@@ -23,7 +23,7 @@ export async function resolveMobileWebHostNavigationRoute(
limit: MOBILE_WEB_WORKSPACE_LIST_LIMIT + 1
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const result = response.result
if (
@@ -0,0 +1,78 @@
/**
* A host RPC the running page will never reach used to arrive as `host_error`, which the bridge
* marks retryable. The page's cached package is keyed per host and opens before any refresh, so it
* can drive a desktop release that predates or postdates it; every method that host lacks answers
* `method_not_found`, and the mobile allowlist answers `forbidden`. Both are structural absences,
* not blips.
*/
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
import {
isRetryableMobileWebBridgeError,
mobileWebBridgeErrorCode,
mobileWebBridgeErrorCodeForHostRpc,
mobileWebBrokerHostRpcError
} from './mobile-web-broker-error'
const GRANT_LIMITS = {
maxRequestBytes: 4096,
maxResponseBytes: 128 * 1024,
maxConcurrent: 2,
rateCapacity: 8,
rateRefillPerSecond: 4
}
function failingClient(code: string): RpcClient {
return {
sendRequest: vi.fn(async () => ({
id: 'r1',
ok: false as const,
error: { code, message: `Method 'accounts.list' is not available` },
_meta: { runtimeId: 'runtime-1' }
})),
subscribe: vi.fn(() => () => {})
} as unknown as RpcClient
}
describe('host RPC error codes', () => {
it.each([
['method_not_found', 'unsupported_capability'],
['method_not_supported', 'unsupported_capability'],
['forbidden', 'unsupported_capability'],
['runtime_error', 'host_error'],
['', 'host_error']
])('maps host code %s to %s', (code, expected) => {
expect(mobileWebBridgeErrorCodeForHostRpc({ code })).toBe(expected)
expect(mobileWebBridgeErrorCode(mobileWebBrokerHostRpcError({ code }))).toBe(expected)
})
it('keeps a structural absence non-retryable and a genuine host failure retryable', () => {
expect(isRetryableMobileWebBridgeError(mobileWebBridgeErrorCodeForHostRpc({}))).toBe(true)
expect(
isRetryableMobileWebBridgeError(
mobileWebBridgeErrorCodeForHostRpc({ code: 'method_not_found' })
)
).toBe(false)
expect(
isRetryableMobileWebBridgeError(mobileWebBridgeErrorCodeForHostRpc({ code: 'forbidden' }))
).toBe(false)
})
it('tolerates a non-string host code', () => {
expect(mobileWebBridgeErrorCodeForHostRpc({ code: 42 })).toBe('host_error')
})
it.each([
['method_not_found', 'unsupported_capability', false],
['forbidden', 'unsupported_capability', false],
['runtime_error', 'host_error', true]
])('reports %s to the page as %s', async (code, expected, retryable) => {
const { client } = createMobileWebBridgeRoundtripFixture({
grants: [{ capability: 'account', operation: 'snapshot', limits: GRANT_LIMITS }],
rpcClient: failingClient(code)
})
await expect(client.account.snapshot()).rejects.toMatchObject({ code: expected, retryable })
})
})
@@ -7,7 +7,7 @@ import {
import type { MobileWebProviderReview } from '../../../src/shared/mobile-web/provider-review-contract'
import type { RpcClient } from '../transport/rpc-client'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-hosted-review-service'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { sanitizeMobileWebProviderReviewDetails } from './mobile-web-provider-review-sanitizer'
import {
assertCurrentRepositoryIdentity,
@@ -91,7 +91,7 @@ async function queryCheckDetails(
...githubProviderReviewTarget(details)
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return MobileWebProviderReviewQueryResultSchema.parse({
workspaceId: payload.workspaceId,
@@ -1,6 +1,6 @@
import type { MobileWebProviderReview } from '../../../src/shared/mobile-web/provider-review-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { sanitizeMobileWebProviderReviewSummary } from './mobile-web-provider-review-sanitizer'
import { readMobileWebSourceControlStatusIdentity } from './mobile-web-source-control-repository-state'
import { assertMobileWebRepositoryIdentity } from './mobile-web-source-control-sync-preflight'
@@ -46,7 +46,7 @@ export async function readHostedReviewSummary(
currentHeadOid: identity.expectedHead
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
if (response.result === null) {
return null
@@ -27,7 +27,7 @@ import {
isMobileWebBrowserFileUrl
} from './mobile-web-browser-file-url-confinement'
import type { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebSessionSnapshot } from './mobile-web-session-snapshot'
import { executeMobileWebSessionQuickCommandOperation } from './mobile-web-session-quick-command-operations'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
@@ -68,7 +68,7 @@ export async function executeMobileWebSessionOperation(args: {
if (hostGatesRequest.success) {
const response = await args.client.sendRequest('status.get')
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const status = response.result as { floatingWorkspaceEnabled?: unknown }
const hostCapabilities = (parseRuntimeStatusCapabilities(response.result) ?? []).filter(
@@ -82,7 +82,7 @@ export async function executeMobileWebSessionOperation(args: {
MobileWebSessionCapabilitiesPayloadSchema.parse(args.payload)
const response = await args.client.sendRequest('status.get')
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const capabilities = parseRuntimeStatusCapabilities(response.result) ?? []
return MobileWebSessionCapabilitiesResultSchema.parse(
@@ -96,7 +96,7 @@ export async function executeMobileWebSessionOperation(args: {
worktree: `id:${hostWorkspaceId}`
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return mobileWebSessionSnapshot(
response.result,
@@ -127,7 +127,7 @@ export async function executeMobileWebSessionOperation(args: {
navigation: 'caller'
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return mobileWebSessionSnapshot(
response.result,
@@ -148,7 +148,7 @@ export async function executeMobileWebSessionOperation(args: {
clientMutationId: args.requestId
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeCreateResult(response.result, payload.workspaceId)
}
@@ -173,7 +173,7 @@ export async function executeMobileWebSessionOperation(args: {
clientMutationId: args.requestId
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeCreateResult(response.result, payload.workspaceId)
}
@@ -193,7 +193,7 @@ export async function executeMobileWebSessionOperation(args: {
activate: true
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeBrowserCreateResult(
response.result,
@@ -211,7 +211,7 @@ export async function executeMobileWebSessionOperation(args: {
reason: 'user'
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeCloseResult(response.result, payload.workspaceId, payload.tabId)
}
@@ -20,7 +20,7 @@ import { isFloatingWorkspaceWorktreeId } from '../session/floating-workspace'
import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers'
import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import type {
MobileWebHostWorkspaceId,
MobileWebWorkspaceAuthority
@@ -47,7 +47,7 @@ export async function executeMobileWebSessionQuickCommandOperation(args: {
mutation
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return projectQuickCommands(response.result, hostWorkspaceId, payload.workspaceId)
}
@@ -89,7 +89,7 @@ async function quickCommandSnapshot(
async function readQuickCommands(client: RpcClient): Promise<TerminalQuickCommand[]> {
const response = await client.sendRequest('settings.getTerminalQuickCommands')
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const commands = parseNormalizedTerminalQuickCommands(
(response.result as { terminalQuickCommands?: unknown }).terminalQuickCommands
@@ -12,6 +12,7 @@ import {
import { MobileWebBridgeSubscriptionClient } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-client'
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 { tolerantMobileWebShellPayload } from '../../../src/shared/mobile-web/shell-payload-tolerance'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
const CONTEXT = {
@@ -68,9 +69,7 @@ describe('mobile web shell response schema corpus', () => {
it('rejects invalid success payloads through every one-shot result schema', async () => {
let caseCount = 0
for (const { name, schema } of resultSchemas) {
const rejected = RESPONSE_PAYLOAD_CORPUS.filter(
(payload) => !schema.safeParse(payload).success
)
const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload))
expect(rejected.length, name).toBeGreaterThanOrEqual(8)
for (const payload of rejected) {
await expectOneShotRejection(schema, payload, name)
@@ -83,9 +82,7 @@ describe('mobile web shell response schema corpus', () => {
it('retires every subscription after an invalid event payload', async () => {
let caseCount = 0
for (const { name, schema } of eventSchemas) {
const rejected = RESPONSE_PAYLOAD_CORPUS.filter(
(payload) => !schema.safeParse(payload).success
)
const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload))
expect(rejected.length, name).toBeGreaterThanOrEqual(8)
for (const payload of rejected) {
await expectSubscriptionRejection(schema, payload, name)
@@ -96,6 +93,12 @@ 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
}
function namedSchemas(suffix: 'ResultSchema' | 'EventSchema'): NamedSchema[] {
const schemas: NamedSchema[] = []
for (const [path, module] of Object.entries(contractModules)) {
@@ -6,7 +6,7 @@ import {
import { sha256 } from '@noble/hashes/sha256'
import { Buffer } from 'buffer/'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
import { sanitizeMobileWebBranchCompare } from './mobile-web-source-control-history-sanitizers'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
@@ -117,7 +117,7 @@ export class MobileWebSourceControlBranchComparePager {
baseRef: payload.baseRef
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
if (mobileWebEncodedByteLength(response.result) > HOST_RESULT_MAX_BYTES) {
throw new MobileWebBrokerError('too_large')
@@ -3,7 +3,7 @@ import {
type MobileWebSourceControlCommitEntry
} from '../../../src/shared/mobile-web/source-control-commit-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export async function assertFreshMobileWebCommitSnapshot(
client: RpcClient,
@@ -18,7 +18,7 @@ export async function assertFreshMobileWebCommitSnapshot(
worktree: `id:${hostWorkspaceId}`
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
assertMobileWebSourceControlCommitPreflight({
result: response.result,
@@ -8,7 +8,7 @@ import {
type MobileWebSourceControlHistoryResult
} from '../../../src/shared/mobile-web/source-control-history-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import {
sanitizeMobileWebBranches,
sanitizeMobileWebCommitCompare,
@@ -50,7 +50,7 @@ export async function executeMobileWebSourceControlHistoryOperation(args: {
worktree: `id:${hostWorkspaceId}`
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeMobileWebBranches(response.result, payload.workspaceId)
}
@@ -63,7 +63,7 @@ export async function executeMobileWebSourceControlHistoryOperation(args: {
...(payload.baseRef ? { baseRef: payload.baseRef } : {})
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeMobileWebHistory(response.result, payload.workspaceId, payload.limit)
}
@@ -86,7 +86,7 @@ export async function executeMobileWebSourceControlHistoryOperation(args: {
commitId: payload.commitId
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeMobileWebCommitCompare(response.result, payload.workspaceId, payload.commitId)
}
@@ -33,7 +33,7 @@ import type {
MobileWebSourceControlReviewTerminalSendResult
} from '../../../src/shared/mobile-web/source-control-review-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { executeMobileWebSourceControlCommit } from './mobile-web-source-control-commit-operation'
import {
executeMobileWebSourceControlHistoryOperation,
@@ -109,7 +109,7 @@ export async function executeMobileWebSourceControlOperation(args: {
reuseLineStats: true
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeMobileWebSourceControlStatus(response.result, payload.workspaceId, payload.limit)
}
@@ -122,7 +122,7 @@ export async function executeMobileWebSourceControlOperation(args: {
staged: payload.area === 'staged'
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return sanitizeMobileWebSourceControlDiff(response.result, payload)
}
@@ -148,7 +148,7 @@ async function executeMutation(
reuseLineStats: true
})
if (!preflight.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(preflight.error)
}
assertMobileWebSourceControlMutationPreflight({
result: preflight.result,
@@ -165,7 +165,7 @@ async function executeMutation(
...(bulk ? { filePaths: relativePaths } : { filePath: relativePaths[0] })
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return MobileWebSourceControlMutationResultSchema.parse({
workspaceId: payload.workspaceId,
@@ -11,7 +11,7 @@ import {
type MobileWebSourceControlReviewState
} from '../../../src/shared/mobile-web/source-control-review-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export async function readMobileWebSourceControlReviewMetadata(args: {
client: RpcClient
@@ -89,7 +89,7 @@ export async function updateMobileWebSourceControlReviewMetadata(args: {
}
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return readMobileWebSourceControlReviewMetadata(args)
}
@@ -17,7 +17,7 @@ import {
import type { RpcClient } from '../transport/rpc-client'
import { isMobileGitUnavailable } from '../source-control/mobile-git-status'
import { activateMobileSessionFileTab } from '../session/mobile-session-file-tab-activation'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { sanitizeMobileWebSourceControlDiff } from './mobile-web-source-control-read-results'
import {
readMobileWebSourceControlReviewMetadata,
@@ -61,7 +61,7 @@ export async function executeMobileWebSourceControlReviewOperation(args: {
...(payload.baseRef ? { baseRef: payload.baseRef } : {})
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return readReviewLink(args.client, args.workspaceAuthority, payload.workspaceId)
}
@@ -121,7 +121,7 @@ export async function executeMobileWebSourceControlReviewOperation(args: {
client: { id: args.terminalClientId, type: 'mobile' }
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const accepted = terminalSendAccepted(response.result)
if (accepted === null) {
@@ -216,7 +216,7 @@ async function executeReviewDiff(
staged: payload.scope === 'staged'
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const result = sanitizeMobileWebSourceControlDiff(response.result, {
workspaceId: payload.workspaceId,
@@ -4,7 +4,7 @@ import type {
} from '../../../src/shared/mobile-web/speech-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { DICTATION_FINISH_TIMEOUT_MS } from '../hooks/mobile-dictation-session-state'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export type MobileWebSpeechSession = {
id: string
@@ -45,7 +45,7 @@ export async function finishMobileWebRemoteSpeechSession(
throw new MobileWebBrokerError('host_error')
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const value = response.result as { text?: unknown }
const text = typeof value.text === 'string' ? value.text.trim().slice(0, 32 * 1024) : ''
@@ -8,7 +8,7 @@ import {
type MobileWebSpeechSetup
} from '../../../src/shared/mobile-web/speech-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export async function loadMobileWebSpeechSetup(
client: RpcClient,
@@ -54,7 +54,7 @@ async function sendSpeechRequest(
): Promise<unknown> {
const response = await client.sendRequest(method, payload)
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
return response.result
}
@@ -41,7 +41,7 @@ import { nativeHostTaskDetailOperations } from '../tasks/native-host-task-detail
import { nativeHostTaskListOperations } from '../tasks/native-host-task-list-operations'
import { nativeHostTaskReadOperations } from '../tasks/native-host-task-read-operations'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebTaskSettings } from './mobile-web-task-bootstrap-projection'
import type { MobileWebTaskTargetAuthority } from './mobile-web-task-target-authority'
import type { MobileWebTaskProjectTablePager } from './mobile-web-task-project-table-pager'
@@ -285,8 +285,8 @@ function pageRepoId(hostRepoId: string, authority: MobileWebWorkspaceAuthority):
}
}
function requireSuccess(response: { ok: boolean }): void {
function requireSuccess(response: { ok: boolean; error?: { code?: unknown } }): void {
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error ?? {})
}
}
@@ -1,6 +1,6 @@
import type { MobileWebTerminalRequest } from '../../../src/shared/mobile-web/terminal-stream-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import type { MobileWebTerminalStreamRecord } from './mobile-web-terminal-flow-control'
type MobileWebTerminalAction = Extract<
@@ -32,6 +32,6 @@ export async function runMobileWebTerminalAction(args: {
})
}
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
}
@@ -14,7 +14,7 @@ import {
import { MobileWebRelativePathSchema } from '../../../src/shared/mobile-web/bridge-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { encodeMobileWebBase64UrlToken } from './mobile-web-base64url-token'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { terminalArtifactFailureCode } from './mobile-web-terminal-artifact-host-error'
import {
displayNameFromTerminalArtifactPath,
@@ -68,7 +68,7 @@ export class MobileWebTerminalArtifactAuthority {
)
this.assertGeneration(generation)
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
const resolved = response.result
if (
@@ -137,6 +137,8 @@ export class MobileWebTerminalLeaseStreams {
viewport: record.viewport,
startSequence: 0,
maxOutstandingBytes: MOBILE_WEB_TERMINAL_MAX_OUTSTANDING_BYTES,
// Constants for the same reason as the multiplex path: the host publishes no floor state and
// no reply-authority verdict over the terminal stream, so neither can be derived here.
inputFloor: 'held',
queryReplyAuthority: true
})
@@ -37,6 +37,9 @@ export function handleMobileWebTerminalMultiplexEvent(args: {
viewport: record.viewport,
startSequence: record.sentSequence,
maxOutstandingBytes: MOBILE_WEB_TERMINAL_MAX_OUTSTANDING_BYTES,
// Constants: the host publishes neither over the terminal stream. `isMobileTerminalQueryReplyAuthority`
// elects one subscriber but is never sent, and opcode-17 WriteUnavailable reports a single
// refused write with no regain signal, so it is not the floor state this field declares.
inputFloor: 'held',
queryReplyAuthority: true
})
@@ -9,7 +9,7 @@ import {
MobileWebWorkspaceUpdateResultSchema
} from '../../../src/shared/mobile-web/workspace-presentation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebWorkspaceActivation } from './mobile-web-workspace-activation'
import { executeMobileWebWorkspaceCreationReadOperation } from './mobile-web-workspace-creation-read-operations'
import { executeMobileWebWorkspaceCreationSourceOperation } from './mobile-web-workspace-creation-source-operations'
@@ -132,11 +132,14 @@ async function readRepositories(
return mobileWebWorkspaceRepositories(response.result, authority)
}
function requireSuccess(response: { ok: boolean }): asserts response is {
function requireSuccess(response: {
ok: boolean
error?: { code?: unknown }
}): asserts response is {
ok: true
result: unknown
} {
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error ?? {})
}
}
@@ -5,7 +5,7 @@ import {
type MobileWebWorkspaceSnapshotResult
} from '../../../src/shared/mobile-web/bridge-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import { mobileWebWorkspaceSnapshotPage } from './mobile-web-workspace-snapshot'
@@ -66,7 +66,7 @@ export class MobileWebWorkspaceSnapshotPager {
limit: MOBILE_WEB_WORKSPACE_LIST_LIMIT + 1
})
if (!response.ok) {
throw new MobileWebBrokerError('host_error')
throw mobileWebBrokerHostRpcError(response.error)
}
if (
mobileWebEncodedByteLength(response.result) > MOBILE_WEB_WORKSPACE_HOST_SNAPSHOT_MAX_BYTES ||
@@ -2,7 +2,8 @@
* A paired client reaches `browser.tabCreate` with a caller-supplied URL, and the page it creates is
* streamed back over `browser.screencast`. Without a fence, `file:///…/id_rsa` renders any file on
* the host into the caller's frames. The native HTML-artifact open is the same call, so the fence
* has to be a workspace-root containment check rather than a scheme ban.
* has to be a workspace-root containment check rather than a scheme ban. "Paired" is every
* authenticated paired socket — phone, web client, remote desktop, remote CLI — not just mobile.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
@@ -173,6 +174,24 @@ describe('browser.tabCreate file: URLs from a paired client', () => {
expect(createTab).not.toHaveBeenCalled()
})
// The gate is `caller.pairedDeviceId`, which `runtime-rpc-pairing.ts` mints for `scope: 'runtime'`
// as well as `'mobile'`. So it also governs the web client, a desktop paired to a remote runtime,
// and remote `orca browser tab create` — breadth as a decision, not a side effect.
it('applies the same fence to a runtime-scope paired client, not only a phone', async () => {
const { runtime, createTab } = createRuntime({ id: WT, path: WORKTREE_PATH })
const caller = { pairedDeviceId: 'device-2', clientKind: 'runtime' as const }
await expect(create(runtime, 'file:///tmp/secrets/id_rsa', caller)).rejects.toThrow(
/outside the requested workspace/
)
expect(createTab).not.toHaveBeenCalled()
await expect(
create(runtime, `file://${WORKTREE_PATH}/build/report.html`, caller)
).resolves.toEqual({ browserPageId: 'page-new' })
expect(createTab).toHaveBeenCalledTimes(1)
})
it('leaves an unpaired local create alone', async () => {
const { runtime, createTab } = createRuntime({
id: WT,
@@ -1,3 +1,4 @@
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'
@@ -17,7 +18,9 @@ export function deliverMobileWebSubscriptionEvent(
fail(new MobileWebBridgeClientError('invalid_message', true))
return
}
const parsed = subscription.eventSchema.safeParse(message.payload)
// 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)
if (!parsed.success) {
fail(new MobileWebBridgeClientError('invalid_message', false))
return
@@ -6,6 +6,7 @@ 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 {
@@ -130,7 +131,8 @@ export class MobileWebOneShotRequestClient {
)
return true
}
const parsed = pending.resultSchema.safeParse(message.payload)
// Shell->page: tolerate a newer shell's additive result rather than failing unretryably.
const parsed = tolerantMobileWebShellPayload(pending.resultSchema).safeParse(message.payload)
if (!parsed.success) {
this.finishWithError(
message.requestId,
@@ -0,0 +1,145 @@
/**
* The shell (APK) authors every result and event; the page is served by the desktop and can be an
* older release. Before this, one field a newer APK added failed the page's `.strict()` parse as
* `invalid_message` with `retryable: false`, which killed the session subscription *and* its
* one-shot fallback on the same byte — "Loading tabs" forever, surviving force-quit.
*/
import { describe, expect, it, vi } from 'vitest'
import {
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
type MobileWebBridgePageMessage,
type MobileWebBridgeShellMessage
} from '../../shared/mobile-web/bridge-contract'
import { MobileWebBridgeClient } from './mobile-web-bridge-client'
const CONTEXT = { shellSessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) }
const REQUEST_ID = 'Q'.repeat(22)
const SUBSCRIPTION_ID = 'S'.repeat(22)
const KNOWN_TAB = {
id: 'tab-1',
title: 'Terminal',
isActive: true,
type: 'terminal',
status: 'ready'
}
/** A snapshot from a shell release the page predates. */
function futureSnapshot(): Record<string, unknown> {
return {
workspaceId: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 4,
activeTabId: 'tab-2',
activeTabType: 'canvas',
sessionRevision: 12,
tabs: [
{ ...KNOWN_TAB, pinnedAt: 1730000000 },
{ id: 'tab-2', title: 'Canvas', isActive: false, type: 'canvas', documentId: 'doc-1' }
],
truncated: false
}
}
function envelope() {
return {
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION as typeof MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
shellSessionId: CONTEXT.shellSessionId,
buildId: CONTEXT.buildId
}
}
function createHarness() {
const messages: MobileWebBridgePageMessage[] = []
const ids = [REQUEST_ID, SUBSCRIPTION_ID]
const client = new MobileWebBridgeClient({
context: CONTEXT,
grants: (['subscribe', 'snapshot'] as const).map((operation) => ({
capability: 'session' as const,
operation,
limits: {
maxRequestBytes: 1024,
maxResponseBytes: 128 * 1024,
maxConcurrent: 2,
rateCapacity: 4,
rateRefillPerSecond: 1
}
})),
postMessage: (message) => {
messages.push(message)
return true
},
createRequestId: () => ids.shift() ?? 'Z'.repeat(22)
})
return { client, messages }
}
function subscriptionAck(): MobileWebBridgeShellMessage {
return {
...envelope(),
type: 'response',
requestId: REQUEST_ID,
status: 'success',
payload: null
}
}
describe('forward-compatible shell payloads', () => {
it('delivers a newer shell snapshot over the subscription with the unknown tab dropped', async () => {
const { client } = createHarness()
const onEvent = vi.fn()
const onError = vi.fn()
const subscription = client.sessionSubscribe({ workspaceId: 'workspace-1' }, onEvent, onError)
client.receive(subscriptionAck())
await subscription.ready
client.receive({
...envelope(),
type: 'event',
subscriptionId: SUBSCRIPTION_ID,
sequence: 0,
payload: futureSnapshot()
})
expect(onError).not.toHaveBeenCalled()
expect(onEvent).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 4,
activeTabId: 'tab-2',
activeTabType: null,
tabs: [KNOWN_TAB],
truncated: false
})
})
it('resolves the one-shot snapshot fallback from the same newer shell', async () => {
const { client } = createHarness()
const pending = client.sessionSnapshot({ workspaceId: 'workspace-1' })
client.receive({
...envelope(),
type: 'response',
requestId: REQUEST_ID,
status: 'success',
payload: futureSnapshot()
})
await expect(pending).resolves.toMatchObject({ activeTabType: null, tabs: [KNOWN_TAB] })
})
it('still fails a snapshot whose known fields are wrong', async () => {
const { client } = createHarness()
const pending = client.sessionSnapshot({ workspaceId: 'workspace-1' })
client.receive({
...envelope(),
type: 'response',
requestId: REQUEST_ID,
status: 'success',
payload: { ...futureSnapshot(), truncated: 'no' }
})
await expect(pending).rejects.toMatchObject({ code: 'invalid_message', retryable: false })
})
})
@@ -65,7 +65,7 @@ describe('mobile web source-control sync request client', () => {
})
})
it('rejects cross-request action identity and undeclared host fields', async () => {
it('rejects cross-request action identity and strips undeclared host fields', async () => {
const checkoutHarness = createHarness()
const checkout = checkoutHarness.client.sourceControlCheckout({
workspaceId: 'workspace-1',
@@ -87,6 +87,9 @@ 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(
@@ -95,7 +98,7 @@ describe('mobile web source-control sync request client', () => {
hostPath: '/private/repository'
})
)
await expect(request).rejects.toMatchObject({ code: 'invalid_message' })
await expect(request).resolves.not.toHaveProperty('hostPath')
})
it('cancels a pending sync request when its workspace owner replaces it', async () => {
@@ -0,0 +1,144 @@
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 { 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('MobileWebSessionSnapshotResultSchema')
expect([...derived].filter((name) => !schemas.has(name))).toEqual([])
})
// 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({})
})
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)
})
})
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
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('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('leaves the source schema strict so page->shell requests keep their fence', () => {
expect(
MobileWebSessionSnapshotResultSchema.safeParse({ ...SNAPSHOT, sessionRevision: 9 }).success
).toBe(false)
})
})
@@ -0,0 +1,144 @@
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. Three 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, and an unknown value for an optional/nullable closed
* set collapses to absent rather than failing its parent.
*
* 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 wrapper = cloned(schema, { ...def, innerType: loosen(def.innerType as AnySchema) })
if (!isClosedSet(def.innerType as AnySchema)) {
return wrapper
}
return wrapper.catch((def.type === 'nullable' ? null : undefined) as never)
}
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)
}
+9
View File
@@ -40,12 +40,21 @@ export type BrowserScreencastDialogResult = { type: 'dialog'; dialogType: string
export type BrowserScreencastDialogClosedResult = { type: 'dialogClosed' }
export type BrowserScreencastErrorResult = { type: 'error'; message: string }
/** Rule 3, ungated: every decoder routes screencast results through an if/else-if chain with no
* else, so a client that predates this member ignores it. The host emits it, so the union has to
* name it or an exhaustive consumer compiles against a shape the wire does not have. */
export type BrowserScreencastNavigationResult = {
type: 'navigation'
tab: { url: string; title: string; canGoBack: boolean; canGoForward: boolean }
}
export type BrowserScreencastResult =
| BrowserScreencastReadyResult
| BrowserScreencastEndResult
| BrowserScreencastDialogResult
| BrowserScreencastDialogClosedResult
| BrowserScreencastErrorResult
| BrowserScreencastNavigationResult
export type BrowserEvalResult = { result: string; origin: string }