diff --git a/src/mobile-web/src/native-shell-channel.test.tsx b/src/mobile-web/src/native-shell-channel.test.tsx index 384a8bc8dda..b11ecc26eae 100644 --- a/src/mobile-web/src/native-shell-channel.test.tsx +++ b/src/mobile-web/src/native-shell-channel.test.tsx @@ -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 diff --git a/src/shared/mobile-web/bridge-contract.test.ts b/src/shared/mobile-web/bridge-contract.test.ts index df5bb8d17ed..cd466fb9fa0 100644 --- a/src/shared/mobile-web/bridge-contract.test.ts +++ b/src/shared/mobile-web/bridge-contract.test.ts @@ -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).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, diff --git a/src/shared/mobile-web/shell-payload-tolerance.test.ts b/src/shared/mobile-web/shell-payload-tolerance.test.ts index 2249bae62a0..d97b7c9c296 100644 --- a/src/shared/mobile-web/shell-payload-tolerance.test.ts +++ b/src/shared/mobile-web/shell-payload-tolerance.test.ts @@ -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) diff --git a/src/shared/mobile-web/shell-payload-tolerance.ts b/src/shared/mobile-web/shell-payload-tolerance.ts index 630d29561e8..fb667f2084e 100644 --- a/src/shared/mobile-web/shell-payload-tolerance.ts +++ b/src/shared/mobile-web/shell-payload-tolerance.ts @@ -9,10 +9,11 @@ const rewritten = new WeakMap() * Rewrites a shell-authored payload schema so an additive change in a newer APK degrades instead of * bricking an older page. The shell (APK) and the page (served by the desktop) ship from different * releases, and a page parse failure is permanent: `invalid_message` is not retryable and nothing - * re-subscribes. Three relaxations, each the forward-compatible reading of a closed shape: unknown + * 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> } })._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)[discriminator]) + ) + .transform(() => absent) as unknown as AnySchema } function isUnion(schema: AnySchema): boolean {