fix(mobile-web): degrade an unknown init resumeRoute instead of failing the page

`init.resumeRoute` is MobileWebResumeRouteSchema.optional(), a discriminated union whose
`kind` is a closed literal set. The shell->page tolerant parse rescued unknown members
inside arrays and unknown values for an optional closed set, but isClosedSet only accepts
enums and literals, so an optional discriminated union of objects got no relaxation: a
page built before a kind existed failed the whole init. That is the worst frame to drop --
init is the page's only grant delivery, so one unrecognized route cost it every capability.

The transform now treats an optional/nullable discriminated union like an optional closed
set, because its discriminant is one. Scoped deliberately to an UNRECOGNIZED discriminant
rather than a blanket .catch on the wrapper: a member the page can name but whose fields
break their bounds is a sender bug, not version skew, and still fails loudly. The existing
'rejects unbounded resume routes' assertion (a 241-character workspaceName on a known
'session' route) therefore keeps failing the parse, and the PII strip on hostPath is
unchanged.

Page->shell stays strict, which is what fences the shell's route memory:
useMobileWebResumeRouteMemory only stores what a strict routeState parse produced, so the
shell can never remember a kind its own build cannot replay. The persisted cold-resume
record (mobile-web-cold-resume-route) stores hostIdentity and hostWorkspaceIdentity with
no kind at all, so it cannot carry one either. The only way the shell holds a route the
current page rejects is a mid-session page downgrade, and that now degrades to the page's
default route on every boot rather than bricking it.

Tests: the transform collapses an unrecognized discriminant and still rejects a malformed
known member and a non-object; a real init carrying kind 'someFutureKind' parses through
both page entry points with resumeRoute absent and both grants intact; the page channel
opens workspaceList and keeps its client; a routeState naming an unknown kind is refused.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-06 15:42:37 -04:00
parent 16e06f2c3d
commit 266d6eded1
4 changed files with 139 additions and 7 deletions
@@ -161,6 +161,30 @@ describe('mobile web native shell channel', () => {
expect(posted.at(-1)).toMatchObject({ type: 'cancel', target: 'request' })
})
it('opens its default route when a newer shell resumes a route kind it cannot name', () => {
const target = window as NativeTestWindow
target.OrcaNative = { postMessage: () => {} }
const hook = renderHook(() => useMobileWebNativeShell(), {
wrapper: MobileWebNativeShellProvider
})
act(() =>
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
...initMessage(),
resumeRoute: { kind: 'someFutureKind', workspaceId: 'opaque-workspace' }
})
})
)
)
// Why: dropping the init instead would cost the page every grant, not one route.
expect(hook.result.current.client).not.toBeNull()
expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' })
expect(hook.result.current.navigationRoute).toEqual({ kind: 'workspaceList' })
})
it('retires pending work when the shell session or build changes', async () => {
const posted: MobileWebBridgePageMessage[] = []
const target = window as NativeTestWindow
@@ -394,6 +394,48 @@ describe('mobile web bridge shell contract', () => {
}
)
it('keeps a route the shell cannot name out of its resume memory', () => {
// Why: page->shell stays strict, so the shell only ever remembers a kind it can replay.
expect(
MobileWebBridgePageMessageSchema.safeParse({
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
type: 'routeState',
shellSessionId: SHELL_SESSION_ID,
buildId: BUILD_ID,
route: { kind: 'someFutureKind', workspaceId: 'opaque-workspace' }
}).success
).toBe(false)
})
it('degrades a resume route kind a newer shell added instead of failing the whole init', () => {
const base = {
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
type: 'init',
shellSessionId: SHELL_SESSION_ID,
buildId: BUILD_ID,
connection: 'connected',
grants: [operationGrant(), operationGrant({ capability: 'terminal', operation: 'input' })]
}
const raw = JSON.stringify({
...base,
resumeRoute: { kind: 'someFutureKind', workspaceId: 'opaque-workspace' }
})
// Why: init is the page's only grant delivery, so a route it cannot name must cost the route.
for (const parsed of [
parseMobileWebBridgeShellMessage(raw, CONTEXT),
parseMobileWebBridgeInitialMessage(raw)
]) {
expect(parsed.ok).toBe(true)
const value = (parsed as Extract<typeof parsed, { ok: true }>).value as {
resumeRoute?: unknown
grants: unknown[]
}
expect(value.resumeRoute).toBeUndefined()
expect(value.grants).toHaveLength(2)
}
})
it('rejects unbounded resume routes and strips host-shaped ones', () => {
const base = {
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
@@ -48,6 +48,35 @@ describe('mobile web shell payload tolerance', () => {
)
})
it('collapses an optional discriminated union the page cannot classify', () => {
const schema = tolerantMobileWebShellPayload(
z
.object({
keep: z.string(),
route: z
.discriminatedUnion('kind', [
z.object({ kind: z.literal('list') }).strict(),
z.object({ kind: z.literal('session'), id: z.string() }).strict()
])
.optional()
})
.strict()
)
expect(schema.safeParse({ keep: 'a', route: { kind: 'futureKind', id: 'x' } })).toEqual({
success: true,
data: { keep: 'a' }
})
expect(schema.safeParse({ keep: 'a', route: { kind: 'session', id: 'x' } })).toEqual({
success: true,
data: { keep: 'a', route: { kind: 'session', id: 'x' } }
})
expect(schema.safeParse({ keep: 'a' }).success).toBe(true)
// A member the page CAN name but whose fields are wrong is a sender bug, not skew.
expect(schema.safeParse({ keep: 'a', route: { kind: 'session' } }).success).toBe(false)
expect(schema.safeParse({ keep: 'a', route: 'session' }).success).toBe(false)
})
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)
@@ -9,10 +9,11 @@ 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
* re-subscribes. Four 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.
* dropped rather than failing the whole array, an unknown value for an optional/nullable closed
* set collapses to absent rather than failing its parent, and an optional/nullable discriminated
* union the page cannot classify collapses the same way.
*
* 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.
@@ -121,11 +122,47 @@ function rebuiltArray(schema: AnySchema, def: SchemaDef): 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
const inner = def.innerType as AnySchema
const absent = (def.type === 'nullable' ? null : undefined) as never
const loosened = loosen(inner)
if (isClosedSet(inner)) {
return cloned(schema, { ...def, innerType: loosened }).catch(absent)
}
return wrapper.catch((def.type === 'nullable' ? null : undefined) as never)
const unclassified = unclassifiedMemberOf(loosened, absent)
return cloned(schema, {
...def,
innerType: unclassified ? z.union([loosened, unclassified]) : loosened
})
}
/**
* A discriminated union is a closed set one level in, so a member named by a discriminant this build
* has never heard of is the same forward-compatible shape as an unknown enum value and reads as
* absent. `init.resumeRoute` is the case that made this load-bearing: a page that failed the whole
* envelope over a route it could have ignored lost every grant with it. Scoped to an unrecognized
* discriminant on purpose -- a member the page CAN name but whose fields break their bounds is a
* sender bug, not version skew, and still fails loudly.
*/
function unclassifiedMemberOf(schema: AnySchema, absent: never): AnySchema | null {
const def = definitionOf(schema)
if (def.type !== 'union' || typeof def.discriminator !== 'string') {
return null
}
const discriminator = def.discriminator
const known = (schema as unknown as { _zod: { propValues?: Record<string, Set<unknown>> } })._zod
.propValues?.[discriminator]
if (!known || known.size === 0) {
return null
}
return z
.unknown()
.refine(
(value) =>
typeof value === 'object' &&
value !== null &&
!known.has((value as Record<string, unknown>)[discriminator])
)
.transform(() => absent) as unknown as AnySchema
}
function isUnion(schema: AnySchema): boolean {