mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user