Merge branch 'fix2-wire-merge' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-04 16:42:47 -04:00
40 changed files with 807 additions and 83 deletions
@@ -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)
}
+9
View File
@@ -40,12 +40,21 @@ export type BrowserScreencastDialogResult = { type: 'dialog'; dialogType: string
export type BrowserScreencastDialogClosedResult = { type: 'dialogClosed' }
export type BrowserScreencastErrorResult = { type: 'error'; message: string }
/** Rule 3, ungated: every decoder routes screencast results through an if/else-if chain with no
* else, so a client that predates this member ignores it. The host emits it, so the union has to
* name it or an exhaustive consumer compiles against a shape the wire does not have. */
export type BrowserScreencastNavigationResult = {
type: 'navigation'
tab: { url: string; title: string; canGoBack: boolean; canGoForward: boolean }
}
export type BrowserScreencastResult =
| BrowserScreencastReadyResult
| BrowserScreencastEndResult
| BrowserScreencastDialogResult
| BrowserScreencastDialogClosedResult
| BrowserScreencastErrorResult
| BrowserScreencastNavigationResult
export type BrowserEvalResult = { result: string; origin: string }