mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
Merge branch 'fix2-wire-merge' into mobile-rearch
This commit is contained in:
@@ -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'
|
||||
@@ -206,6 +207,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