From 4c7fd1aec67d245c5dcf5950d0b3d6477cbd0e39 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 4 Sep 2026 16:41:17 -0400 Subject: [PATCH] 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 --- .../mobile-hybrid-webview-architecture.md | 26 ++++ .../mobile-web-account-operations.ts | 9 +- .../mobile-web-agent-history-pager.ts | 4 +- .../mobile-web-agent-history-resume.ts | 6 +- .../src/mobile-web/mobile-web-broker-error.ts | 25 +++ ...mobile-web-browser-file-url-confinement.ts | 4 +- .../mobile-web-browser-operations.ts | 4 +- .../mobile-web-file-open-operation.ts | 4 +- .../mobile-web/mobile-web-file-operations.ts | 10 +- .../mobile-web-host-navigation-route.ts | 4 +- .../mobile-web-host-rpc-error-code.test.ts | 78 ++++++++++ .../mobile-web-provider-review-query.ts | 4 +- .../mobile-web-provider-review-state.ts | 4 +- .../mobile-web-session-operations.ts | 18 +-- ...le-web-session-quick-command-operations.ts | 6 +- ...e-web-shell-response-schema-corpus.test.ts | 15 +- ...web-source-control-branch-compare-pager.ts | 4 +- ...ile-web-source-control-commit-preflight.ts | 4 +- ...e-web-source-control-history-operations.ts | 8 +- .../mobile-web-source-control-operations.ts | 10 +- ...bile-web-source-control-review-metadata.ts | 4 +- ...le-web-source-control-review-operations.ts | 8 +- .../mobile-web-speech-session-rpc.ts | 4 +- .../mobile-web-speech-setup-operations.ts | 4 +- .../mobile-web-task-read-operations.ts | 6 +- .../mobile-web/mobile-web-terminal-actions.ts | 4 +- .../mobile-web-terminal-artifact-authority.ts | 4 +- .../mobile-web-terminal-lease-streams.ts | 2 + .../mobile-web-terminal-multiplex-events.ts | 3 + .../mobile-web-workspace-operations.ts | 9 +- .../mobile-web-workspace-snapshot-pager.ts | 4 +- ...browser-tab-create-paired-file-url.test.ts | 21 ++- ...-web-bridge-subscription-event-delivery.ts | 5 +- .../src/mobile-web-one-shot-request-client.ts | 4 +- ...b-shell-forward-compatible-payload.test.ts | 145 ++++++++++++++++++ ...source-control-sync-request-client.test.ts | 7 +- .../shell-payload-tolerance-census.test.ts | 144 +++++++++++++++++ .../shell-payload-tolerance.test.ts | 112 ++++++++++++++ .../mobile-web/shell-payload-tolerance.ts | 144 +++++++++++++++++ src/shared/runtime-browser-contracts.ts | 9 ++ 40 files changed, 807 insertions(+), 83 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts create mode 100644 src/mobile-web/src/mobile-web-shell-forward-compatible-payload.test.ts create mode 100644 src/shared/mobile-web/shell-payload-tolerance-census.test.ts create mode 100644 src/shared/mobile-web/shell-payload-tolerance.test.ts create mode 100644 src/shared/mobile-web/shell-payload-tolerance.ts diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 01394061068..964c87f4fb7 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -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 diff --git a/mobile/src/mobile-web/mobile-web-account-operations.ts b/mobile/src/mobile-web/mobile-web-account-operations.ts index ee16ea7bb3c..98297e14bef 100644 --- a/mobile/src/mobile-web/mobile-web-account-operations.ts +++ b/mobile/src/mobile-web/mobile-web-account-operations.ts @@ -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 ?? {}) } } diff --git a/mobile/src/mobile-web/mobile-web-agent-history-pager.ts b/mobile/src/mobile-web/mobile-web-agent-history-pager.ts index f1c3281a58c..61a6de0bd5b 100644 --- a/mobile/src/mobile-web/mobile-web-agent-history-pager.ts +++ b/mobile/src/mobile-web/mobile-web-agent-history-pager.ts @@ -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 { const response = await client.sendRequest(method, params) if (!response.ok) { - throw new MobileWebBrokerError('host_error') + throw mobileWebBrokerHostRpcError(response.error) } return response.result } diff --git a/mobile/src/mobile-web/mobile-web-agent-history-resume.ts b/mobile/src/mobile-web/mobile-web-agent-history-resume.ts index 00f4255fac3..38face6e30f 100644 --- a/mobile/src/mobile-web/mobile-web-agent-history-resume.ts +++ b/mobile/src/mobile-web/mobile-web-agent-history-resume.ts @@ -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 { 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 } diff --git a/mobile/src/mobile-web/mobile-web-broker-error.ts b/mobile/src/mobile-web/mobile-web-broker-error.ts index 7106e2aeaaf..fee02c55c5c 100644 --- a/mobile/src/mobile-web/mobile-web-broker-error.ts +++ b/mobile/src/mobile-web/mobile-web-broker-error.ts @@ -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' + } +} diff --git a/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts index 4b2d6adaff1..06b4cbb97de 100644 --- a/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts +++ b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts @@ -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 ( diff --git a/mobile/src/mobile-web/mobile-web-browser-operations.ts b/mobile/src/mobile-web/mobile-web-browser-operations.ts index f79bf716fab..72b6844f15a 100644 --- a/mobile/src/mobile-web/mobile-web-browser-operations.ts +++ b/mobile/src/mobile-web/mobile-web-browser-operations.ts @@ -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>): unknown { if (!response.ok) { - throw new MobileWebBrokerError('host_error') + throw mobileWebBrokerHostRpcError(response.error) } return response.result } diff --git a/mobile/src/mobile-web/mobile-web-file-open-operation.ts b/mobile/src/mobile-web/mobile-web-file-open-operation.ts index e6a3ad12927..5a04aad7c68 100644 --- a/mobile/src/mobile-web/mobile-web-file-open-operation.ts +++ b/mobile/src/mobile-web/mobile-web-file-open-operation.ts @@ -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, diff --git a/mobile/src/mobile-web/mobile-web-file-operations.ts b/mobile/src/mobile-web/mobile-web-file-operations.ts index 946ed8fd15a..fd08f550b0b 100644 --- a/mobile/src/mobile-web/mobile-web-file-operations.ts +++ b/mobile/src/mobile-web/mobile-web-file-operations.ts @@ -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) } diff --git a/mobile/src/mobile-web/mobile-web-host-navigation-route.ts b/mobile/src/mobile-web/mobile-web-host-navigation-route.ts index 9951ef17dc2..766bd3b723b 100644 --- a/mobile/src/mobile-web/mobile-web-host-navigation-route.ts +++ b/mobile/src/mobile-web/mobile-web-host-navigation-route.ts @@ -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 ( diff --git a/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts b/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts new file mode 100644 index 00000000000..50e6097e556 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts @@ -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 }) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-provider-review-query.ts b/mobile/src/mobile-web/mobile-web-provider-review-query.ts index 6b30d91fa07..87867d4003c 100644 --- a/mobile/src/mobile-web/mobile-web-provider-review-query.ts +++ b/mobile/src/mobile-web/mobile-web-provider-review-query.ts @@ -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, diff --git a/mobile/src/mobile-web/mobile-web-provider-review-state.ts b/mobile/src/mobile-web/mobile-web-provider-review-state.ts index 1b358840756..18aaabae80d 100644 --- a/mobile/src/mobile-web/mobile-web-provider-review-state.ts +++ b/mobile/src/mobile-web/mobile-web-provider-review-state.ts @@ -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 diff --git a/mobile/src/mobile-web/mobile-web-session-operations.ts b/mobile/src/mobile-web/mobile-web-session-operations.ts index 704cabcb11d..bc408c0d6df 100644 --- a/mobile/src/mobile-web/mobile-web-session-operations.ts +++ b/mobile/src/mobile-web/mobile-web-session-operations.ts @@ -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) } diff --git a/mobile/src/mobile-web/mobile-web-session-quick-command-operations.ts b/mobile/src/mobile-web/mobile-web-session-quick-command-operations.ts index e5591ab1be8..6549166588a 100644 --- a/mobile/src/mobile-web/mobile-web-session-quick-command-operations.ts +++ b/mobile/src/mobile-web/mobile-web-session-quick-command-operations.ts @@ -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 { 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 diff --git a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts index 5886ae8ce03..fcdb37323b3 100644 --- a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts @@ -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)) { diff --git a/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts index 09728e27e03..ead6e1e1c7a 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts @@ -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') diff --git a/mobile/src/mobile-web/mobile-web-source-control-commit-preflight.ts b/mobile/src/mobile-web/mobile-web-source-control-commit-preflight.ts index eb5736608ff..f85ac50231d 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-commit-preflight.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-commit-preflight.ts @@ -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, diff --git a/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts index 6e6830b0792..9b81f2e5ed2 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts @@ -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) } diff --git a/mobile/src/mobile-web/mobile-web-source-control-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-operations.ts index 5bdd7abe482..c96fdfd1536 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-operations.ts @@ -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, diff --git a/mobile/src/mobile-web/mobile-web-source-control-review-metadata.ts b/mobile/src/mobile-web/mobile-web-source-control-review-metadata.ts index 7f2be318355..14bf1436ca9 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-review-metadata.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-review-metadata.ts @@ -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) } diff --git a/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts index bf08048d384..b8aacb3ea7d 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts @@ -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, diff --git a/mobile/src/mobile-web/mobile-web-speech-session-rpc.ts b/mobile/src/mobile-web/mobile-web-speech-session-rpc.ts index bbda7a0e8b2..e5884a4e02d 100644 --- a/mobile/src/mobile-web/mobile-web-speech-session-rpc.ts +++ b/mobile/src/mobile-web/mobile-web-speech-session-rpc.ts @@ -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) : '' diff --git a/mobile/src/mobile-web/mobile-web-speech-setup-operations.ts b/mobile/src/mobile-web/mobile-web-speech-setup-operations.ts index 3fd5a044f22..cdc9ce4a095 100644 --- a/mobile/src/mobile-web/mobile-web-speech-setup-operations.ts +++ b/mobile/src/mobile-web/mobile-web-speech-setup-operations.ts @@ -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 { const response = await client.sendRequest(method, payload) if (!response.ok) { - throw new MobileWebBrokerError('host_error') + throw mobileWebBrokerHostRpcError(response.error) } return response.result } diff --git a/mobile/src/mobile-web/mobile-web-task-read-operations.ts b/mobile/src/mobile-web/mobile-web-task-read-operations.ts index ce0a20cafa4..c04504099a2 100644 --- a/mobile/src/mobile-web/mobile-web-task-read-operations.ts +++ b/mobile/src/mobile-web/mobile-web-task-read-operations.ts @@ -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 ?? {}) } } diff --git a/mobile/src/mobile-web/mobile-web-terminal-actions.ts b/mobile/src/mobile-web/mobile-web-terminal-actions.ts index a17341d0edb..608bbbc731d 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-actions.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-actions.ts @@ -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) } } diff --git a/mobile/src/mobile-web/mobile-web-terminal-artifact-authority.ts b/mobile/src/mobile-web/mobile-web-terminal-artifact-authority.ts index 28384d66eaf..12495340e53 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-artifact-authority.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-artifact-authority.ts @@ -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 ( diff --git a/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts b/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts index 4592b19b05f..500e61e3a04 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-lease-streams.ts @@ -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 }) diff --git a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts index 5ec48ec1c43..fe676d7a833 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts @@ -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 }) diff --git a/mobile/src/mobile-web/mobile-web-workspace-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-operations.ts index eb854b8b8b0..74d752007f1 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-operations.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-operations.ts @@ -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 ?? {}) } } diff --git a/mobile/src/mobile-web/mobile-web-workspace-snapshot-pager.ts b/mobile/src/mobile-web/mobile-web-workspace-snapshot-pager.ts index 19c0c94fb83..e872e010027 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-snapshot-pager.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-snapshot-pager.ts @@ -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 || diff --git a/src/main/runtime/browser-tab-create-paired-file-url.test.ts b/src/main/runtime/browser-tab-create-paired-file-url.test.ts index f2e966696ad..ccf0b6a718e 100644 --- a/src/main/runtime/browser-tab-create-paired-file-url.test.ts +++ b/src/main/runtime/browser-tab-create-paired-file-url.test.ts @@ -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, diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts b/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts index 3527b1a93ca..97a5781b7ae 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-event-delivery.ts @@ -1,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 diff --git a/src/mobile-web/src/mobile-web-one-shot-request-client.ts b/src/mobile-web/src/mobile-web-one-shot-request-client.ts index 3f29ed74c31..ac8888beb49 100644 --- a/src/mobile-web/src/mobile-web-one-shot-request-client.ts +++ b/src/mobile-web/src/mobile-web-one-shot-request-client.ts @@ -6,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, diff --git a/src/mobile-web/src/mobile-web-shell-forward-compatible-payload.test.ts b/src/mobile-web/src/mobile-web-shell-forward-compatible-payload.test.ts new file mode 100644 index 00000000000..63dbcf34d76 --- /dev/null +++ b/src/mobile-web/src/mobile-web-shell-forward-compatible-payload.test.ts @@ -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 { + 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 }) + }) +}) diff --git a/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts b/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts index c188895a447..777cbcb87a6 100644 --- a/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts +++ b/src/mobile-web/src/mobile-web-source-control-sync-request-client.test.ts @@ -65,7 +65,7 @@ describe('mobile web source-control sync request client', () => { }) }) - it('rejects cross-request action identity and 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 () => { diff --git a/src/shared/mobile-web/shell-payload-tolerance-census.test.ts b/src/shared/mobile-web/shell-payload-tolerance-census.test.ts new file mode 100644 index 00000000000..21d72ac0962 --- /dev/null +++ b/src/shared/mobile-web/shell-payload-tolerance-census.test.ts @@ -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>> { + const schemas = new Map>() + const files = readdirSync(__dirname).filter( + (name) => name.endsWith('-contract.ts') && !name.includes('.test.') + ) + for (const file of files) { + const module = (await import(/* @vite-ignore */ `./${file.slice(0, -3)}`)) as Record< + string, + unknown + > + for (const [name, value] of Object.entries(module)) { + if (name.endsWith('Schema') && isSchema(value)) { + schemas.set(name, value) + } + } + } + return schemas +} + +function isSchema(value: unknown): value is z.ZodType { + return typeof value === 'object' && value !== null && '_zod' in value +} + +/** + * Schema names the page parses in the shell->page direction: the result schema of every one-shot + * request and the event schema of every subscription. Derived from the page source so a new + * operation joins the ratchet without anyone remembering to list it. + */ +function shellAuthoredSchemaNames(): Set { + const names = new Set() + for (const file of readdirSync(PAGE_DIR).filter( + (name) => name.endsWith('.ts') && !name.includes('.test.') + )) { + const text = readFileSync(join(PAGE_DIR, file), 'utf8') + for (const match of text.matchAll( + /\.request(?:<[^(]*>)?\(\s*'[A-Za-z]+',\s*'[A-Za-z0-9]+',([\s\S]{0,400}?)\n\s*\)/g + )) { + const schemas = [...match[1]!.matchAll(/\b([A-Za-z0-9_]*Schema)\b/g)].map((name) => name[1]!) + if (schemas.length === 2) { + names.add(schemas[1]!) + } + } + for (const match of text.matchAll(/\beventSchema:\s*([A-Za-z0-9_]*Schema)\b/g)) { + names.add(match[1]!) + } + } + return names +} + +/** Object nodes that still reject unknown keys, reached through any `_zod.def` child. */ +function strictPaths(schema: z.ZodType): string[] { + const found: string[] = [] + const seen = new Set() + const visit = (node: unknown, path: string): void => { + if (isSchema(node)) { + if (seen.has(node)) { + return + } + seen.add(node) + const def = (node as unknown as { _zod: { def: Record } })._zod.def + const catchall = def.catchall + if ( + def.type === 'object' && + isSchema(catchall) && + (catchall as unknown as { _zod: { def: { type: string } } })._zod.def.type === 'never' + ) { + found.push(path) + } + visit(def, path) + return + } + if (Array.isArray(node)) { + node.forEach((entry, index) => visit(entry, `${path}[${index}]`)) + return + } + if (typeof node === 'object' && node !== null) { + for (const [key, value] of Object.entries(node)) { + // A `lazy` getter only reveals its subtree when called; no other function in a def is safe + // to invoke. + visit(key === 'getter' && typeof value === 'function' ? value() : value, `${path}.${key}`) + } + } + } + visit(schema, '') + return found +} + +describe('mobile web shell payload tolerance census', () => { + let schemas: Map> + const derived = shellAuthoredSchemaNames() + + beforeAll(async () => { + schemas = await exportedSchemas() + }) + + it('derives the shell-authored schema set from the page instead of a hand list', () => { + expect(schemas.size).toBeGreaterThanOrEqual(300) + expect(derived.size).toBeGreaterThanOrEqual(100) + expect([...derived]).toContain('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 = {} + 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) + }) +}) diff --git a/src/shared/mobile-web/shell-payload-tolerance.test.ts b/src/shared/mobile-web/shell-payload-tolerance.test.ts new file mode 100644 index 00000000000..2249bae62a0 --- /dev/null +++ b/src/shared/mobile-web/shell-payload-tolerance.test.ts @@ -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) + }) +}) diff --git a/src/shared/mobile-web/shell-payload-tolerance.ts b/src/shared/mobile-web/shell-payload-tolerance.ts new file mode 100644 index 00000000000..630d29561e8 --- /dev/null +++ b/src/shared/mobile-web/shell-payload-tolerance.ts @@ -0,0 +1,144 @@ +import { z } from 'zod' + +type AnySchema = z.ZodType +type SchemaDef = Record & { type: string } + +const rewritten = new WeakMap() + +/** + * Rewrites a shell-authored payload schema so an additive change in a newer APK degrades instead of + * bricking an older page. The shell (APK) and the page (served by the desktop) ship from different + * releases, and a page parse failure is permanent: `invalid_message` is not retryable and nothing + * re-subscribes. 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(schema: z.ZodType): z.ZodType { + return loosen(schema as AnySchema) as unknown as z.ZodType +} + +function loosen(schema: AnySchema): AnySchema { + const cached = rewritten.get(schema) + if (cached) { + return cached + } + const built = rebuild(schema) + rewritten.set(schema, built) + return built +} + +function definitionOf(schema: AnySchema): SchemaDef { + return (schema as unknown as { _zod: { def: SchemaDef } })._zod.def +} + +function cloned(schema: AnySchema, def: SchemaDef): AnySchema { + return (schema as unknown as { clone: (def: SchemaDef) => AnySchema }).clone(def) +} + +function rebuild(schema: AnySchema): AnySchema { + const def = definitionOf(schema) + switch (def.type) { + case 'object': + return rebuiltObject(schema, def) + case 'array': + return rebuiltArray(schema, def) + case 'union': + return cloned(schema, { ...def, options: (def.options as AnySchema[]).map(loosen) }) + case 'optional': + case 'nullable': + return rebuiltClosedSetWrapper(schema, def) + case 'nonoptional': + case 'readonly': + case 'default': + case 'prefault': + case 'catch': + case 'promise': + return cloned(schema, { ...def, innerType: loosen(def.innerType as AnySchema) }) + case 'lazy': { + const getter = def.getter as () => AnySchema + return cloned(schema, { ...def, getter: () => loosen(getter()) }) + } + case 'pipe': + return cloned(schema, { + ...def, + in: loosen(def.in as AnySchema), + out: loosen(def.out as AnySchema) + }) + case 'intersection': + return cloned(schema, { + ...def, + left: loosen(def.left as AnySchema), + right: loosen(def.right as AnySchema) + }) + case 'record': + case 'map': + case 'set': + return cloned(schema, { ...def, valueType: loosen(def.valueType as AnySchema) }) + case 'tuple': + return cloned(schema, { + ...def, + items: (def.items as AnySchema[]).map(loosen), + rest: def.rest ? loosen(def.rest as AnySchema) : def.rest + }) + default: + return schema + } +} + +function rebuiltObject(schema: AnySchema, def: SchemaDef): AnySchema { + const shape = Object.fromEntries( + Object.entries(def.shape as Record).map(([key, value]) => [ + key, + loosen(value) + ]) + ) + const catchall = def.catchall as AnySchema | undefined + const strict = catchall !== undefined && definitionOf(catchall).type === 'never' + return cloned(schema, { + ...def, + shape, + catchall: strict || catchall === undefined ? undefined : loosen(catchall) + }) +} + +/** Length checks stay on the raw array so a wire-size cap still rejects before any member parses. */ +function rebuiltArray(schema: AnySchema, def: SchemaDef): AnySchema { + const element = loosen(def.element as AnySchema) + if (!isUnion(def.element as AnySchema)) { + return cloned(schema, { ...def, element }) + } + return cloned(schema, { ...def, element: z.unknown() }).transform((items) => + (items as unknown[]).flatMap((item) => { + const parsed = element.safeParse(item) + return parsed.success ? [parsed.data] : [] + }) + ) as unknown as AnySchema +} + +/** An unknown member of a closed set reads as "absent" so it cannot fail the payload around it. */ +function rebuiltClosedSetWrapper(schema: AnySchema, def: SchemaDef): AnySchema { + const 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) +} diff --git a/src/shared/runtime-browser-contracts.ts b/src/shared/runtime-browser-contracts.ts index a259e24c48c..cca9838a075 100644 --- a/src/shared/runtime-browser-contracts.ts +++ b/src/shared/runtime-browser-contracts.ts @@ -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 }